zerochat 9.5.0__tar.gz → 9.7.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.7.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
@@ -43,7 +43,7 @@ The interface runs entirely in the browser as static files: history and settings
43
43
 
44
44
  ### Quick start
45
45
 
46
- 1. **Web (client-only):** Open [ZeroChat on GitHub Pages](https://albalday.github.io/zerochat/zerochat.html), configure a connection profile, and start chatting.
46
+ 1. **Web (client-only):** Open [ZeroChat on GitHub Pages](https://albalday.github.io/zerochat/zerochat.html), configure a profile, and start chatting.
47
47
  2. **With local computer tools (Python backend):**
48
48
 
49
49
  ```bash
@@ -93,7 +93,7 @@ La interfaz se ejecuta íntegramente en el navegador como archivos estáticos: e
93
93
 
94
94
  ### Inicio rápido
95
95
 
96
- 1. **Web directa (solo cliente):** Abre [ZeroChat en GitHub Pages](https://albalday.github.io/zerochat/zerochat.html), configura un perfil de conexión y comienza.
96
+ 1. **Web directa (solo cliente):** Abre [ZeroChat en GitHub Pages](https://albalday.github.io/zerochat/zerochat.html), configura un perfil y comienza.
97
97
  2. **Con herramientas del equipo (backend Python):**
98
98
 
99
99
  ```bash
@@ -21,7 +21,7 @@ The interface runs entirely in the browser as static files: history and settings
21
21
 
22
22
  ### Quick start
23
23
 
24
- 1. **Web (client-only):** Open [ZeroChat on GitHub Pages](https://albalday.github.io/zerochat/zerochat.html), configure a connection profile, and start chatting.
24
+ 1. **Web (client-only):** Open [ZeroChat on GitHub Pages](https://albalday.github.io/zerochat/zerochat.html), configure a profile, and start chatting.
25
25
  2. **With local computer tools (Python backend):**
26
26
 
27
27
  ```bash
@@ -71,7 +71,7 @@ La interfaz se ejecuta íntegramente en el navegador como archivos estáticos: e
71
71
 
72
72
  ### Inicio rápido
73
73
 
74
- 1. **Web directa (solo cliente):** Abre [ZeroChat en GitHub Pages](https://albalday.github.io/zerochat/zerochat.html), configura un perfil de conexión y comienza.
74
+ 1. **Web directa (solo cliente):** Abre [ZeroChat en GitHub Pages](https://albalday.github.io/zerochat/zerochat.html), configura un perfil y comienza.
75
75
  2. **Con herramientas del equipo (backend Python):**
76
76
 
77
77
  ```bash
@@ -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.7.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.7.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
@@ -43,7 +43,7 @@ The interface runs entirely in the browser as static files: history and settings
43
43
 
44
44
  ### Quick start
45
45
 
46
- 1. **Web (client-only):** Open [ZeroChat on GitHub Pages](https://albalday.github.io/zerochat/zerochat.html), configure a connection profile, and start chatting.
46
+ 1. **Web (client-only):** Open [ZeroChat on GitHub Pages](https://albalday.github.io/zerochat/zerochat.html), configure a profile, and start chatting.
47
47
  2. **With local computer tools (Python backend):**
48
48
 
49
49
  ```bash
@@ -93,7 +93,7 @@ La interfaz se ejecuta íntegramente en el navegador como archivos estáticos: e
93
93
 
94
94
  ### Inicio rápido
95
95
 
96
- 1. **Web directa (solo cliente):** Abre [ZeroChat en GitHub Pages](https://albalday.github.io/zerochat/zerochat.html), configura un perfil de conexión y comienza.
96
+ 1. **Web directa (solo cliente):** Abre [ZeroChat en GitHub Pages](https://albalday.github.io/zerochat/zerochat.html), configura un perfil y comienza.
97
97
  2. **Con herramientas del equipo (backend Python):**
98
98
 
99
99
  ```bash
@@ -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.7.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."""
@@ -1101,7 +1101,7 @@ def job_details(job: CommandJob) -> dict:
1101
1101
  # Generated from services/ by scripts/build-managed-services.mjs. Do not edit.
1102
1102
  # ==============================================================================
1103
1103
 
1104
- MANAGED_SERVICE_FILES: dict[str, str] = json.loads("{\n \"composio/installer.json\": \"{\\n \\\"schemaVersion\\\": 1,\\n \\\"type\\\": \\\"npm\\\",\\n \\\"product\\\": {\\n \\\"package\\\": \\\"mcp-remote\\\",\\n \\\"version\\\": \\\"0.13.5\\\"\\n },\\n \\\"requirements\\\": {\\n \\\"node\\\": {\\n \\\"minimumMajor\\\": 18\\n }\\n }\\n}\\n\",\n \"composio/service.json\": \"{\\n \\\"schemaVersion\\\": 1,\\n \\\"id\\\": \\\"composio\\\",\\n \\\"displayName\\\": {\\n \\\"es\\\": \\\"Composio Connect\\\",\\n \\\"en\\\": \\\"Composio Connect\\\"\\n },\\n \\\"description\\\": {\\n \\\"es\\\": \\\"MCP remoto para acceder a aplicaciones como Gmail, Google Drive, Slack o GitHub. Composio solicita la autorización de cada aplicación cuando sea necesaria.\\\",\\n \\\"en\\\": \\\"Remote MCP for apps such as Gmail, Google Drive, Slack, and GitHub. Composio requests authorization for each app when it is needed.\\\"\\n },\\n \\\"help\\\": {\\n \\\"url\\\": \\\"https://docs.composio.dev/docs/composio-connect\\\",\\n \\\"label\\\": {\\n \\\"es\\\": \\\"Guía oficial de Composio Connect\\\",\\n \\\"en\\\": \\\"Composio Connect official guide\\\"\\n }\\n },\\n \\\"enabledByDefault\\\": false,\\n \\\"transport\\\": \\\"stdio\\\",\\n \\\"remote\\\": {\\n \\\"type\\\": \\\"mcp-remote\\\",\\n \\\"transport\\\": \\\"streamable-http\\\",\\n \\\"url\\\": \\\"https://connect.composio.dev/mcp\\\",\\n \\\"authentication\\\": \\\"composio-connect\\\"\\n },\\n \\\"launch\\\": {\\n \\\"executable\\\": \\\"${nodeExecutable}\\\",\\n \\\"args\\\": [\\n \\\"${serviceDir}/node_modules/mcp-remote/dist/proxy.js\\\",\\n \\\"https://connect.composio.dev/mcp\\\",\\n \\\"--protocol\\\",\\n \\\"legacy\\\",\\n \\\"--static-oauth-client-metadata\\\",\\n \\\"{\\\\\\\"client_name\\\\\\\":\\\\\\\"zerochat-mcp-remote\\\\\\\"}\\\",\\n \\\"--auth-timeout\\\",\\n \\\"120\\\"\\n ],\\n \\\"cwd\\\": \\\"${serviceDir}\\\",\\n \\\"env\\\": {\\n \\\"HOME\\\": \\\"${serviceDir}\\\",\\n \\\"USERPROFILE\\\": \\\"${serviceDir}\\\"\\n },\\n \\\"handshakeTimeoutSeconds\\\": 150\\n }\\n}\\n\",\n \"ejemplo/ejemplo_mcp_server.py\": \"#!/usr/bin/env python3\\nimport json\\nimport sys\\n\\ndef reply(req_id, result=None, error=None):\\n resp = {\\\"jsonrpc\\\": \\\"2.0\\\", \\\"id\\\": req_id}\\n if error:\\n resp[\\\"error\\\"] = error\\n else:\\n resp[\\\"result\\\"] = result\\n sys.stdout.write(json.dumps(resp) + \\\"\\\\n\\\")\\n sys.stdout.flush()\\n\\nfor raw in sys.stdin:\\n try:\\n req = json.loads(raw)\\n except Exception:\\n continue\\n req_id = req.get(\\\"id\\\")\\n method = req.get(\\\"method\\\")\\n params = req.get(\\\"params\\\", {})\\n if method == \\\"initialize\\\":\\n reply(req_id, {\\n \\\"protocolVersion\\\": \\\"2024-11-05\\\",\\n \\\"serverInfo\\\": {\\\"name\\\": \\\"ZeroChat MCP Example\\\", \\\"version\\\": \\\"1.0.0\\\"},\\n \\\"capabilities\\\": {\\\"tools\\\": {}}\\n })\\n elif method == \\\"tools/list\\\":\\n reply(req_id, {\\\"tools\\\": [{\\n \\\"name\\\": \\\"echo\\\",\\n \\\"description\\\": \\\"Echo back a message for testing.\\\",\\n \\\"inputSchema\\\": {\\n \\\"type\\\": \\\"object\\\",\\n \\\"properties\\\": {\\\"message\\\": {\\\"type\\\": \\\"string\\\", \\\"description\\\": \\\"Message to echo.\\\"}},\\n \\\"required\\\": [\\\"message\\\"]\\n }\\n }]})\\n elif method == \\\"tools/call\\\":\\n if params.get(\\\"name\\\") != \\\"echo\\\":\\n reply(req_id, error={\\\"code\\\": -32601, \\\"message\\\": \\\"Tool not found\\\"})\\n continue\\n msg = params.get(\\\"arguments\\\", {}).get(\\\"message\\\", \\\"\\\")\\n reply(req_id, {\\n \\\"content\\\": [{\\\"type\\\": \\\"text\\\", \\\"text\\\": f\\\"echo: {msg}\\\"}],\\n \\\"isError\\\": False\\n })\\n\",\n \"ejemplo/installer.json.example\": \"{\\n \\\"schemaVersion\\\": 1,\\n \\\"type\\\": \\\"npm\\\",\\n \\\"product\\\": {\\n \\\"package\\\": \\\"@scope/mcp-package\\\",\\n \\\"version\\\": \\\"1.2.3\\\"\\n },\\n \\\"requirements\\\": {\\n \\\"node\\\": {\\n \\\"minimumMajor\\\": 18\\n }\\n }\\n}\\n\",\n \"ejemplo/README.md\": \"# Crear un MCP gestionado por ZeroChat\\n\\nEsta carpeta es la referencia para crear servicios MCP gestionados. La ubicación de producción es el directorio personal del usuario, seguido de `zerochat/services` (por ejemplo, `/home/usuario/zerochat/services`). No uses `~`, `$HOME` ni una ruta relativa como `services/`: primero identifica la ruta absoluta del directorio personal y úsala para localizar este archivo. El nuevo servicio se crea como carpeta hermana de `ejemplo`, nunca dentro de ella.\\n\\n## Procedimiento obligatorio\\n\\n1. Lee `README.md`, `service.json` y `installer.json.example` de la carpeta `ejemplo` situada en el directorio de datos de ZeroChat.\\n2. Consulta solo la documentación oficial del MCP solicitado.\\n3. Crea una carpeta con un identificador seguro y descriptivo junto a `ejemplo`. Usa un `id` único en `service.json`, también cuando copies otro servicio.\\n4. Crea `service.json` copiando esta estructura y adapta únicamente los valores necesarios.\\n5. Crea `installer.json` solo si el MCP se distribuye como paquete npm. Copia `installer.json.example`, fija la versión exacta y declara la versión mínima de Node.js.\\n6. Añade `help.url` con la página oficial de instalación y etiquetas en español e inglés.\\n7. Si requiere una API key, declara la variable que exige el MCP en el objeto `env` de `launch` dentro de `service.json` con el valor literal `PASTE_API_KEY_HERE`. No pidas la clave al usuario ni la escribas en el chat: indica la ruta absoluta del JSON y el campo que debe editar a mano. El usuario guardará su clave únicamente en el JSON local de su carpeta propia.\\n8. No instales ni inicies el servicio. Al terminar, informa de los archivos creados, fuentes oficiales y requisitos pendientes.\\n\\n## Límites de ZeroChat\\n\\nZeroChat gestiona procesos MCP por `stdio`. Puede instalar paquetes npm mediante `installer.json` y arrancarlos con `${nodeExecutable}`. También puede arrancar un ejecutable o script ya disponible con `launch`.\\n\\nZeroChat también puede usar `mcp-remote` como puente para un MCP remoto HTTP/SSE con OAuth o API key. Decláralo como un servicio `stdio`: el proceso local debe ser `mcp-remote`, mientras que `remote` describe el endpoint remoto para la interfaz. Conserva las credenciales y el almacén OAuth dentro de `${serviceDir}`.\\n\\n## Contratos JSON\\n\\n- `service.json` es obligatorio. Conserva `schemaVersion`, `id`, textos bilingües, `transport: \\\"stdio\\\"` y `launch`.\\n- En `launch`, usa `${serviceDir}`, `${nodeExecutable}` o `${pythonExecutable}`; no uses rutas absolutas de usuario.\\n- `launch.handshakeTimeoutSeconds` (15 s por defecto) limita el arranque y `launch.callTimeoutSeconds` (30 s por defecto) cada llamada a una herramienta; súbelo para MCP lentos, como la automatización de navegador.\\n- `installer.json` es opcional y hoy solo admite `type: \\\"npm\\\"`.\\n- `help` es opcional, pero debe incluirse en todo servicio creado:\\n\\n```json\\n\\\"help\\\": {\\n \\\"url\\\": \\\"https://documentacion-oficial.example/install\\\",\\n \\\"label\\\": { \\\"es\\\": \\\"Instalación oficial\\\", \\\"en\\\": \\\"Official installation\\\" }\\n}\\n```\\n\\n## Prompt para el usuario\\n\\nSustituye únicamente `<nombre-del-mcp>`:\\n\\n```text\\nPara instalar el MCP <nombre-del-mcp>, lee primero services/ejemplo/README.md y sigue exactamente sus instrucciones.\\n```\\n\\n## Servicios remotos y claves API\\n\\n`launch.env` es el objeto `env` dentro de `launch` en `service.json`, no un archivo. El agente deja únicamente el marcador; el usuario introduce la clave real a mano en ese JSON. No crees un archivo `launch.env` o `.env`.\\n\\nPara un remoto, lee también `service.json` e `installer.json` de la carpeta hermana `composio`. Crea una carpeta nueva con nombre e `id` propios; usa Composio como ejemplo del puente local, conservando `transport: \\\"stdio\\\"`. Consulta la documentación oficial del proveedor y de [mcp-remote](https://github.com/punkpeye/mcp-remote#custom-headers) para adaptar endpoint, transporte, cabeceras y autenticación. No copies sin verificar las opciones `--protocol`, los metadatos de cliente ni los parámetros OAuth específicos de Composio. Conserva `HOME` y `USERPROFILE` dentro de `${serviceDir}`.\\n\\nPara un MCP local que requiera una variable, deja este campo dentro de `launch`, conservando sus demás campos y usando el nombre de variable documentado por el MCP:\\n\\n```json\\n\\\"env\\\": {\\n \\\"SERVICE_API_KEY\\\": \\\"PASTE_API_KEY_HERE\\\"\\n}\\n```\\n\\nPara un remoto que acepte `Authorization: Bearer`, los campos dentro de `launch` pueden ser:\\n\\n```json\\n\\\"args\\\": [\\n \\\"${serviceDir}/node_modules/mcp-remote/dist/proxy.js\\\",\\n \\\"https://mcp.example.com/mcp\\\",\\n \\\"--header\\\",\\n \\\"Authorization: Bearer ${REMOTE_API_KEY}\\\"\\n],\\n\\\"env\\\": {\\n \\\"HOME\\\": \\\"${serviceDir}\\\",\\n \\\"USERPROFILE\\\": \\\"${serviceDir}\\\",\\n \\\"REMOTE_API_KEY\\\": \\\"PASTE_API_KEY_HERE\\\"\\n}\\n```\\n\\nAdapta la cabecera y su formato a lo que exige el proveedor. `remote.authentication` solo describe el servicio; no configura la autenticación. La referencia `${REMOTE_API_KEY}` en la cabecera la resuelve `mcp-remote`. ZeroChat no resuelve variables de entorno genéricas: en `launch.env`, `${SERVICE_API_KEY}` se pasaría como texto literal. Solo sustituye `${serviceDir}`, `${nodeExecutable}`, `${pythonExecutable}` y `${option:...}`. No pongas claves reales en argumentos ni en URLs.\\n\\n### Pasos para el usuario\\n\\n1. Usa una carpeta propia bajo el directorio de servicios, con nombre e `id` únicos. Si copias un servicio incluido, copia sus definiciones, no sus dependencias ni credenciales OAuth. No edites `ejemplo`, `composio`, `playwright` ni `memory`: sus archivos distribuidos se restauran al arrancar.\\n2. Abre el `service.json` de tu carpeta y sustituye `PASTE_API_KEY_HERE` por la clave real en `launch.env`. Mantén el JSON válido y los demás campos. No necesitas `export`, `.env` ni otro JSON de credenciales.\\n3. Restringe el archivo a tu usuario con `chmod 600 /ruta/absoluta/zerochat/services/mi-servicio/service.json` en Linux o Termux. La clave queda en texto plano: no compartas el archivo ni lo subas al repositorio o al chat.\\n4. Inicia el servicio desde Ajustes → MCP. Si la nueva carpeta no aparece, reinicia ZeroChat y conecta de nuevo. Para cambiar una clave, detén primero el servicio, edita el JSON y vuelve a iniciarlo.\\n5. ZeroChat conserva los servicios propios al reiniciar y actualizar. Borrar el directorio de datos elimina también esas claves: guarda una copia privada si la necesitas.\\n\\n## English: creating local and remote services\\n\\n1. Locate the user's absolute home directory and read `README.md`, `service.json` and `installer.json.example` in `zerochat/services/ejemplo`.\\n2. Read the requested MCP's official documentation. Create a sibling folder with its own name and unique JSON `id`; do not modify bundled definitions. Preserve bilingual text, `schemaVersion`, `transport: \\\"stdio\\\"`, `launch` and official `help` links. `launch.handshakeTimeoutSeconds` (default 15 s) bounds startup and `launch.callTimeoutSeconds` (default 30 s) each tool call; raise it for slow MCPs such as browser automation.\\n3. For npm services, add `installer.json` with an exact package version and minimum Node.js major version. Otherwise use an existing executable or script.\\n4. For remote HTTP/SSE MCPs, also read the sibling `composio` JSON files and use its managed `mcp-remote` bridge as the example. Adapt endpoint, transport and authentication to the official provider and bridge documentation. Verify Composio-specific protocol and OAuth options before copying them. Keep `HOME` and `USERPROFILE` isolated inside `${serviceDir}`.\\n5. If an API key is required, put the literal `PASTE_API_KEY_HERE` under the required variable in the `env` object under `launch` inside `service.json`. `launch.env` is a JSON field, not a file. The user enters the actual key manually in that JSON; do not create a `launch.env` or `.env` file. Do not request the user's key or place real secrets in chat, arguments or URLs. Give the absolute JSON path and exact field to edit. For remote header authentication, use `--header` with the provider's required header and a reference to the variable, as shown above; `mcp-remote` resolves that reference. `remote.authentication` is descriptive only.\\n6. Do not install or start the service. Report created files, official sources and outstanding requirements; the user will start it from Settings → MCP.\\n\\nThe user replaces `PASTE_API_KEY_HERE` directly in their own folder's JSON, preserves valid JSON and existing fields, and restricts the file with `chmod 600 /absolute/path/zerochat/services/my-service/service.json` on Linux/Termux. No shell export, `.env` or separate credentials JSON is needed. ZeroChat only expands service/interpreter paths and `${option:...}`, not generic environment placeholders in `launch.env`. The key is stored in plain text: do not share or commit the file or paste it into chat.\\n\\nIf a new folder does not appear, restart ZeroChat and reconnect. Stop an active service before editing its key, then start it again. User-created folders survive restarts and updates. Bundled definitions are restored on startup: copy them into a new folder and change the JSON `id` to customize them, without copying dependencies or OAuth credentials. Deleting the data directory also deletes custom services and their keys; keep a private backup if needed.\\n\",\n \"ejemplo/service.json\": \"{\\n \\\"schemaVersion\\\": 1,\\n \\\"id\\\": \\\"ejemplo\\\",\\n \\\"displayName\\\": {\\n \\\"es\\\": \\\"Ejemplo de MCP\\\",\\n \\\"en\\\": \\\"MCP example\\\"\\n },\\n \\\"description\\\": {\\n \\\"es\\\": \\\"Plantilla mínima para crear servicios MCP gestionados por ZeroChat. Muestra el protocolo stdio y la estructura de configuración que debe seguir un nuevo MCP.\\\",\\n \\\"en\\\": \\\"Minimal template for creating ZeroChat-managed MCP services. It demonstrates the stdio protocol and configuration structure for a new MCP.\\\"\\n },\\n \\\"help\\\": {\\n \\\"url\\\": \\\"help/mcp.html#crear-mcp-con-agente\\\",\\n \\\"label\\\": {\\n \\\"es\\\": \\\"Guía para crear un MCP gestionado\\\",\\n \\\"en\\\": \\\"Guide to creating a managed MCP\\\"\\n }\\n },\\n \\\"enabledByDefault\\\": false,\\n \\\"transport\\\": \\\"stdio\\\",\\n \\\"launch\\\": {\\n \\\"executable\\\": \\\"${pythonExecutable}\\\",\\n \\\"args\\\": [\\\"${serviceDir}/ejemplo_mcp_server.py\\\"],\\n \\\"cwd\\\": \\\"${serviceDir}\\\",\\n \\\"env\\\": {},\\n \\\"handshakeTimeoutSeconds\\\": 10\\n }\\n}\\n\",\n \"memory/installer.json\": \"{\\n \\\"schemaVersion\\\": 1,\\n \\\"type\\\": \\\"npm\\\",\\n \\\"product\\\": {\\n \\\"package\\\": \\\"@modelcontextprotocol/server-memory\\\",\\n \\\"version\\\": \\\"2026.8.31\\\"\\n },\\n \\\"requirements\\\": {\\n \\\"node\\\": {\\n \\\"minimumMajor\\\": 18\\n }\\n }\\n}\\n\",\n \"memory/service.json\": \"{\\n \\\"schemaVersion\\\": 1,\\n \\\"id\\\": \\\"memory\\\",\\n \\\"displayName\\\": {\\n \\\"es\\\": \\\"Memoria y Grafos (Knowledge Graph)\\\",\\n \\\"en\\\": \\\"Memory & Knowledge Graph\\\"\\n },\\n \\\"description\\\": {\\n \\\"es\\\": \\\"Almacenamiento persistente de entidades, preferencias y contexto histórico estructurado en un grafo de conocimiento.\\\",\\n \\\"en\\\": \\\"Persistent storage of entities, preferences, and historical context structured as a knowledge graph.\\\"\\n },\\n \\\"enabledByDefault\\\": false,\\n \\\"transport\\\": \\\"stdio\\\",\\n \\\"launch\\\": {\\n \\\"executable\\\": \\\"${nodeExecutable}\\\",\\n \\\"args\\\": [\\\"${serviceDir}/node_modules/@modelcontextprotocol/server-memory/dist/index.js\\\"],\\n \\\"cwd\\\": \\\"${serviceDir}\\\",\\n \\\"env\\\": {\\n \\\"MEMORY_FILE_PATH\\\": \\\"${serviceDir}/memory.jsonl\\\"\\n },\\n \\\"handshakeTimeoutSeconds\\\": 30\\n }\\n}\\n\\n\",\n \"playwright/installer.json\": \"{\\n \\\"schemaVersion\\\": 1,\\n \\\"type\\\": \\\"npm\\\",\\n \\\"product\\\": {\\n \\\"package\\\": \\\"@playwright/mcp\\\",\\n \\\"version\\\": \\\"0.0.81\\\",\\n \\\"browser\\\": \\\"chromium\\\"\\n },\\n \\\"requirements\\\": {\\n \\\"node\\\": {\\n \\\"minimumMajor\\\": 18\\n }\\n }\\n}\\n\",\n \"playwright/service.json\": \"{\\n \\\"schemaVersion\\\": 1,\\n \\\"id\\\": \\\"playwright\\\",\\n \\\"displayName\\\": {\\n \\\"es\\\": \\\"Playwright MCP\\\",\\n \\\"en\\\": \\\"Playwright MCP\\\"\\n },\\n \\\"description\\\": {\\n \\\"es\\\": \\\"Automatización de navegador mediante el servidor MCP oficial de Playwright.\\\",\\n \\\"en\\\": \\\"Browser automation through the official Playwright MCP server.\\\"\\n },\\n \\\"enabledByDefault\\\": false,\\n \\\"transport\\\": \\\"stdio\\\",\\n \\\"launch\\\": {\\n \\\"executable\\\": \\\"${nodeExecutable}\\\",\\n \\\"args\\\": [\\\"${serviceDir}/node_modules/@playwright/mcp/cli.js\\\", \\\"--browser=chromium\\\"],\\n \\\"cwd\\\": \\\"${serviceDir}\\\",\\n \\\"env\\\": {},\\n \\\"handshakeTimeoutSeconds\\\": 30,\\n \\\"callTimeoutSeconds\\\": 120\\n },\\n \\\"options\\\": [\\n {\\n \\\"id\\\": \\\"headless\\\",\\n \\\"type\\\": \\\"boolean\\\",\\n \\\"label\\\": {\\n \\\"es\\\": \\\"Navegación en segundo plano (Headless)\\\",\\n \\\"en\\\": \\\"Headless background mode\\\"\\n },\\n \\\"description\\\": {\\n \\\"es\\\": \\\"Desactívalo para ver la ventana del navegador durante la automatización\\\",\\n \\\"en\\\": \\\"Disable to display the browser window during automation\\\"\\n },\\n \\\"default\\\": true,\\n \\\"argsWhenTrue\\\": [\\\"--headless\\\"],\\n \\\"argsWhenFalse\\\": []\\n }\\n ]\\n}\\n\\n\"\n}")
1104
+ MANAGED_SERVICE_FILES: dict[str, str] = json.loads("{\n \"composio/installer.json\": \"{\\n \\\"schemaVersion\\\": 1,\\n \\\"type\\\": \\\"npm\\\",\\n \\\"product\\\": {\\n \\\"package\\\": \\\"mcp-remote\\\",\\n \\\"version\\\": \\\"0.13.5\\\"\\n },\\n \\\"requirements\\\": {\\n \\\"node\\\": {\\n \\\"minimumMajor\\\": 18\\n }\\n }\\n}\\n\",\n \"composio/service.json\": \"{\\n \\\"schemaVersion\\\": 1,\\n \\\"id\\\": \\\"composio\\\",\\n \\\"displayName\\\": {\\n \\\"es\\\": \\\"Composio Connect\\\",\\n \\\"en\\\": \\\"Composio Connect\\\"\\n },\\n \\\"description\\\": {\\n \\\"es\\\": \\\"MCP remoto para acceder a aplicaciones como Gmail, Google Drive, Slack o GitHub. Composio solicita la autorización de cada aplicación cuando sea necesaria.\\\",\\n \\\"en\\\": \\\"Remote MCP for apps such as Gmail, Google Drive, Slack, and GitHub. Composio requests authorization for each app when it is needed.\\\"\\n },\\n \\\"help\\\": {\\n \\\"url\\\": \\\"https://docs.composio.dev/docs/composio-connect\\\",\\n \\\"label\\\": {\\n \\\"es\\\": \\\"Guía oficial de Composio Connect\\\",\\n \\\"en\\\": \\\"Composio Connect official guide\\\"\\n }\\n },\\n \\\"enabledByDefault\\\": false,\\n \\\"transport\\\": \\\"stdio\\\",\\n \\\"remote\\\": {\\n \\\"type\\\": \\\"mcp-remote\\\",\\n \\\"transport\\\": \\\"streamable-http\\\",\\n \\\"url\\\": \\\"https://connect.composio.dev/mcp\\\",\\n \\\"authentication\\\": \\\"composio-connect\\\"\\n },\\n \\\"launch\\\": {\\n \\\"executable\\\": \\\"${nodeExecutable}\\\",\\n \\\"args\\\": [\\n \\\"${serviceDir}/node_modules/mcp-remote/dist/proxy.js\\\",\\n \\\"https://connect.composio.dev/mcp\\\",\\n \\\"--protocol\\\",\\n \\\"legacy\\\",\\n \\\"--static-oauth-client-metadata\\\",\\n \\\"{\\\\\\\"client_name\\\\\\\":\\\\\\\"zerochat-mcp-remote\\\\\\\"}\\\",\\n \\\"--auth-timeout\\\",\\n \\\"120\\\"\\n ],\\n \\\"cwd\\\": \\\"${serviceDir}\\\",\\n \\\"env\\\": {\\n \\\"HOME\\\": \\\"${serviceDir}\\\",\\n \\\"USERPROFILE\\\": \\\"${serviceDir}\\\"\\n },\\n \\\"handshakeTimeoutSeconds\\\": 150\\n }\\n}\\n\",\n \"ejemplo/ejemplo_mcp_server.py\": \"#!/usr/bin/env python3\\nimport json\\nimport sys\\n\\ndef reply(req_id, result=None, error=None):\\n resp = {\\\"jsonrpc\\\": \\\"2.0\\\", \\\"id\\\": req_id}\\n if error:\\n resp[\\\"error\\\"] = error\\n else:\\n resp[\\\"result\\\"] = result\\n sys.stdout.write(json.dumps(resp) + \\\"\\\\n\\\")\\n sys.stdout.flush()\\n\\nfor raw in sys.stdin:\\n try:\\n req = json.loads(raw)\\n except Exception:\\n continue\\n req_id = req.get(\\\"id\\\")\\n method = req.get(\\\"method\\\")\\n params = req.get(\\\"params\\\", {})\\n if method == \\\"initialize\\\":\\n reply(req_id, {\\n \\\"protocolVersion\\\": \\\"2024-11-05\\\",\\n \\\"serverInfo\\\": {\\\"name\\\": \\\"ZeroChat MCP Example\\\", \\\"version\\\": \\\"1.0.0\\\"},\\n \\\"capabilities\\\": {\\\"tools\\\": {}}\\n })\\n elif method == \\\"tools/list\\\":\\n reply(req_id, {\\\"tools\\\": [{\\n \\\"name\\\": \\\"echo\\\",\\n \\\"description\\\": \\\"Echo back a message for testing.\\\",\\n \\\"inputSchema\\\": {\\n \\\"type\\\": \\\"object\\\",\\n \\\"properties\\\": {\\\"message\\\": {\\\"type\\\": \\\"string\\\", \\\"description\\\": \\\"Message to echo.\\\"}},\\n \\\"required\\\": [\\\"message\\\"]\\n }\\n }]})\\n elif method == \\\"tools/call\\\":\\n if params.get(\\\"name\\\") != \\\"echo\\\":\\n reply(req_id, error={\\\"code\\\": -32601, \\\"message\\\": \\\"Tool not found\\\"})\\n continue\\n msg = params.get(\\\"arguments\\\", {}).get(\\\"message\\\", \\\"\\\")\\n reply(req_id, {\\n \\\"content\\\": [{\\\"type\\\": \\\"text\\\", \\\"text\\\": f\\\"echo: {msg}\\\"}],\\n \\\"isError\\\": False\\n })\\n\",\n \"ejemplo/installer.json.example\": \"{\\n \\\"schemaVersion\\\": 1,\\n \\\"type\\\": \\\"npm\\\",\\n \\\"product\\\": {\\n \\\"package\\\": \\\"@scope/mcp-package\\\",\\n \\\"version\\\": \\\"1.2.3\\\"\\n },\\n \\\"requirements\\\": {\\n \\\"node\\\": {\\n \\\"minimumMajor\\\": 18\\n }\\n }\\n}\\n\",\n \"ejemplo/README.md\": \"# Crear un MCP gestionado por ZeroChat\\n\\nEsta carpeta es la referencia para crear servicios MCP gestionados. La ubicación de producción es el directorio personal del usuario, seguido de `zerochat/services` (por ejemplo, `/home/usuario/zerochat/services`). No uses `~`, `$HOME` ni una ruta relativa como `services/`: primero identifica la ruta absoluta del directorio personal y úsala para localizar este archivo. El nuevo servicio se crea como carpeta hermana de `ejemplo`, nunca dentro de ella.\\n\\n## Procedimiento obligatorio\\n\\n1. Lee `README.md`, `service.json` y `installer.json.example` de la carpeta `ejemplo` situada en el directorio de datos de ZeroChat.\\n2. Consulta solo la documentación oficial del MCP solicitado.\\n3. Crea una carpeta con un identificador seguro y descriptivo junto a `ejemplo`. Usa un `id` único en `service.json`, también cuando copies otro servicio.\\n4. Crea `service.json` copiando esta estructura y adapta únicamente los valores necesarios.\\n5. Crea `installer.json` solo si el MCP se distribuye como paquete npm. Copia `installer.json.example`, fija la versión exacta y declara la versión mínima de Node.js.\\n6. Añade `help.url` con la página oficial de instalación y etiquetas en español e inglés.\\n7. Si requiere una API key, declara la variable que exige el MCP en el objeto `env` de `launch` dentro de `service.json` con el valor literal `PASTE_API_KEY_HERE`. No pidas la clave al usuario ni la escribas en el chat: indica la ruta absoluta del JSON y el campo que debe editar a mano. El usuario guardará su clave únicamente en el JSON local de su carpeta propia.\\n8. No instales ni inicies el servicio. Al terminar, informa de los archivos creados, fuentes oficiales y requisitos pendientes.\\n\\n## Límites de ZeroChat\\n\\nZeroChat gestiona procesos MCP por `stdio`. Puede instalar paquetes npm mediante `installer.json` y arrancarlos con `${nodeExecutable}`. También puede arrancar un ejecutable o script ya disponible con `launch`.\\n\\nZeroChat también puede usar `mcp-remote` como puente para un MCP remoto HTTP/SSE con OAuth o API key. Decláralo como un servicio `stdio`: el proceso local debe ser `mcp-remote`, mientras que `remote` describe el endpoint remoto para la interfaz. Conserva las credenciales y el almacén OAuth dentro de `${serviceDir}`.\\n\\n## Contratos JSON\\n\\n- `service.json` es obligatorio. Conserva `schemaVersion`, `id`, textos bilingües, `transport: \\\"stdio\\\"` y `launch`.\\n- En `launch`, usa `${serviceDir}`, `${nodeExecutable}` o `${pythonExecutable}`; no uses rutas absolutas de usuario.\\n- `launch.handshakeTimeoutSeconds` (15 s por defecto) limita el arranque y `launch.callTimeoutSeconds` (30 s por defecto) cada llamada a una herramienta; súbelo para MCP lentos, como la automatización de navegador.\\n- `installer.json` es opcional y hoy solo admite `type: \\\"npm\\\"`.\\n- `help` es opcional, pero debe incluirse en todo servicio creado:\\n\\n```json\\n\\\"help\\\": {\\n \\\"url\\\": \\\"https://documentacion-oficial.example/install\\\",\\n \\\"label\\\": { \\\"es\\\": \\\"Instalación oficial\\\", \\\"en\\\": \\\"Official installation\\\" }\\n}\\n```\\n\\n## Prompt para el usuario\\n\\nSustituye únicamente `<nombre-del-mcp>`:\\n\\n```text\\nPara instalar el MCP <nombre-del-mcp>, lee primero services/ejemplo/README.md y sigue exactamente sus instrucciones.\\n```\\n\\n## Servicios remotos y claves API\\n\\n`launch.env` es el objeto `env` dentro de `launch` en `service.json`, no un archivo. El agente deja únicamente el marcador; el usuario introduce la clave real a mano en ese JSON. No crees un archivo `launch.env` o `.env`.\\n\\nPara un remoto, lee también `service.json` e `installer.json` de la carpeta hermana `composio`. Crea una carpeta nueva con nombre e `id` propios; usa Composio como ejemplo del puente local, conservando `transport: \\\"stdio\\\"`. Consulta la documentación oficial del proveedor y de [mcp-remote](https://github.com/punkpeye/mcp-remote#custom-headers) para adaptar endpoint, transporte, cabeceras y autenticación. No copies sin verificar las opciones `--protocol`, los metadatos de cliente ni los parámetros OAuth específicos de Composio. Conserva `HOME` y `USERPROFILE` dentro de `${serviceDir}`.\\n\\nPara un MCP local que requiera una variable, deja este campo dentro de `launch`, conservando sus demás campos y usando el nombre de variable documentado por el MCP:\\n\\n```json\\n\\\"env\\\": {\\n \\\"SERVICE_API_KEY\\\": \\\"PASTE_API_KEY_HERE\\\"\\n}\\n```\\n\\nPara un remoto que acepte `Authorization: Bearer`, los campos dentro de `launch` pueden ser:\\n\\n```json\\n\\\"args\\\": [\\n \\\"${serviceDir}/node_modules/mcp-remote/dist/proxy.js\\\",\\n \\\"https://mcp.example.com/mcp\\\",\\n \\\"--header\\\",\\n \\\"Authorization: Bearer ${REMOTE_API_KEY}\\\"\\n],\\n\\\"env\\\": {\\n \\\"HOME\\\": \\\"${serviceDir}\\\",\\n \\\"USERPROFILE\\\": \\\"${serviceDir}\\\",\\n \\\"REMOTE_API_KEY\\\": \\\"PASTE_API_KEY_HERE\\\"\\n}\\n```\\n\\nAdapta la cabecera y su formato a lo que exige el proveedor. `remote.authentication` solo describe el servicio; no configura la autenticación. La referencia `${REMOTE_API_KEY}` en la cabecera la resuelve `mcp-remote`. ZeroChat no resuelve variables de entorno genéricas: en `launch.env`, `${SERVICE_API_KEY}` se pasaría como texto literal. Solo sustituye `${serviceDir}`, `${nodeExecutable}`, `${pythonExecutable}` y `${option:...}`. No pongas claves reales en argumentos ni en URLs.\\n\\n### Pasos para el usuario\\n\\n1. Usa una carpeta propia bajo el directorio de servicios, con nombre e `id` únicos. Si copias un servicio incluido, copia sus definiciones, no sus dependencias ni credenciales OAuth. No edites `ejemplo`, `composio`, `playwright` ni `memory`: sus archivos distribuidos se restauran al arrancar.\\n2. Abre el `service.json` de tu carpeta y sustituye `PASTE_API_KEY_HERE` por la clave real en `launch.env`. Mantén el JSON válido y los demás campos. No necesitas `export`, `.env` ni otro JSON de credenciales.\\n3. Restringe el archivo a tu usuario con `chmod 600 /ruta/absoluta/zerochat/services/mi-servicio/service.json` en Linux o Termux. La clave queda en texto plano: no compartas el archivo ni lo subas al repositorio o al chat.\\n4. Inicia el servicio desde Ajustes → MCP. Si la nueva carpeta no aparece, reinicia ZeroChat y conecta de nuevo. Para cambiar una clave, detén primero el servicio, edita el JSON y vuelve a iniciarlo.\\n5. ZeroChat conserva los servicios propios al reiniciar y actualizar. Borrar el directorio de datos elimina también esas claves: guarda una copia privada si la necesitas.\\n\\n## English: creating local and remote services\\n\\n1. Locate the user's absolute home directory and read `README.md`, `service.json` and `installer.json.example` in `zerochat/services/ejemplo`.\\n2. Read the requested MCP's official documentation. Create a sibling folder with its own name and unique JSON `id`; do not modify bundled definitions. Preserve bilingual text, `schemaVersion`, `transport: \\\"stdio\\\"`, `launch` and official `help` links. `launch.handshakeTimeoutSeconds` (default 15 s) bounds startup and `launch.callTimeoutSeconds` (default 30 s) each tool call; raise it for slow MCPs such as browser automation.\\n3. For npm services, add `installer.json` with an exact package version and minimum Node.js major version. Otherwise use an existing executable or script.\\n4. For remote HTTP/SSE MCPs, also read the sibling `composio` JSON files and use its managed `mcp-remote` bridge as the example. Adapt endpoint, transport and authentication to the official provider and bridge documentation. Verify Composio-specific protocol and OAuth options before copying them. Keep `HOME` and `USERPROFILE` isolated inside `${serviceDir}`.\\n5. If an API key is required, put the literal `PASTE_API_KEY_HERE` under the required variable in the `env` object under `launch` inside `service.json`. `launch.env` is a JSON field, not a file. The user enters the actual key manually in that JSON; do not create a `launch.env` or `.env` file. Do not request the user's key or place real secrets in chat, arguments or URLs. Give the absolute JSON path and exact field to edit. For remote header authentication, use `--header` with the provider's required header and a reference to the variable, as shown above; `mcp-remote` resolves that reference. `remote.authentication` is descriptive only.\\n6. Do not install or start the service. Report created files, official sources and outstanding requirements; the user will start it from Settings → MCP.\\n\\nThe user replaces `PASTE_API_KEY_HERE` directly in their own folder's JSON, preserves valid JSON and existing fields, and restricts the file with `chmod 600 /absolute/path/zerochat/services/my-service/service.json` on Linux/Termux. No shell export, `.env` or separate credentials JSON is needed. ZeroChat only expands service/interpreter paths and `${option:...}`, not generic environment placeholders in `launch.env`. The key is stored in plain text: do not share or commit the file or paste it into chat.\\n\\nIf a new folder does not appear, restart ZeroChat and reconnect. Stop an active service before editing its key, then start it again. User-created folders survive restarts and updates. Bundled definitions are restored on startup: copy them into a new folder and change the JSON `id` to customize them, without copying dependencies or OAuth credentials. Deleting the data directory also deletes custom services and their keys; keep a private backup if needed.\\n\",\n \"ejemplo/service.json\": \"{\\n \\\"schemaVersion\\\": 1,\\n \\\"id\\\": \\\"ejemplo\\\",\\n \\\"displayName\\\": {\\n \\\"es\\\": \\\"Ejemplo de MCP\\\",\\n \\\"en\\\": \\\"MCP example\\\"\\n },\\n \\\"description\\\": {\\n \\\"es\\\": \\\"Plantilla mínima para crear servicios MCP gestionados por ZeroChat. Muestra el protocolo stdio y la estructura de configuración que debe seguir un nuevo MCP.\\\",\\n \\\"en\\\": \\\"Minimal template for creating ZeroChat-managed MCP services. It demonstrates the stdio protocol and configuration structure for a new MCP.\\\"\\n },\\n \\\"help\\\": {\\n \\\"url\\\": \\\"help/mcp.html#crear-mcp-con-agente\\\",\\n \\\"label\\\": {\\n \\\"es\\\": \\\"Guía para crear un MCP gestionado\\\",\\n \\\"en\\\": \\\"Guide to creating a managed MCP\\\"\\n }\\n },\\n \\\"enabledByDefault\\\": false,\\n \\\"transport\\\": \\\"stdio\\\",\\n \\\"launch\\\": {\\n \\\"executable\\\": \\\"${pythonExecutable}\\\",\\n \\\"args\\\": [\\\"${serviceDir}/ejemplo_mcp_server.py\\\"],\\n \\\"cwd\\\": \\\"${serviceDir}\\\",\\n \\\"env\\\": {},\\n \\\"handshakeTimeoutSeconds\\\": 10\\n }\\n}\\n\",\n \"memory/installer.json\": \"{\\n \\\"schemaVersion\\\": 1,\\n \\\"type\\\": \\\"npm\\\",\\n \\\"product\\\": {\\n \\\"package\\\": \\\"@modelcontextprotocol/server-memory\\\",\\n \\\"version\\\": \\\"2026.8.31\\\"\\n },\\n \\\"requirements\\\": {\\n \\\"node\\\": {\\n \\\"minimumMajor\\\": 18\\n }\\n }\\n}\\n\",\n \"memory/service.json\": \"{\\n \\\"schemaVersion\\\": 1,\\n \\\"id\\\": \\\"memory\\\",\\n \\\"displayName\\\": {\\n \\\"es\\\": \\\"Memoria y Grafos (Knowledge Graph)\\\",\\n \\\"en\\\": \\\"Memory & Knowledge Graph\\\"\\n },\\n \\\"description\\\": {\\n \\\"es\\\": \\\"Almacenamiento persistente de entidades, preferencias y contexto histórico estructurado en un grafo de conocimiento.\\\",\\n \\\"en\\\": \\\"Persistent storage of entities, preferences, and historical context structured as a knowledge graph.\\\"\\n },\\n \\\"enabledByDefault\\\": false,\\n \\\"transport\\\": \\\"stdio\\\",\\n \\\"launch\\\": {\\n \\\"executable\\\": \\\"${nodeExecutable}\\\",\\n \\\"args\\\": [\\\"${serviceDir}/node_modules/@modelcontextprotocol/server-memory/dist/index.js\\\"],\\n \\\"cwd\\\": \\\"${serviceDir}\\\",\\n \\\"env\\\": {\\n \\\"MEMORY_FILE_PATH\\\": \\\"${serviceDir}/memory.jsonl\\\"\\n },\\n \\\"handshakeTimeoutSeconds\\\": 30\\n }\\n}\\n\\n\",\n \"playwright/installer.json\": \"{\\n \\\"schemaVersion\\\": 1,\\n \\\"type\\\": \\\"npm\\\",\\n \\\"product\\\": {\\n \\\"package\\\": \\\"@playwright/mcp\\\",\\n \\\"version\\\": \\\"0.0.81\\\",\\n \\\"browser\\\": \\\"chromium\\\"\\n },\\n \\\"requirements\\\": {\\n \\\"node\\\": {\\n \\\"minimumMajor\\\": 18\\n }\\n }\\n}\\n\",\n \"playwright/service.json\": \"{\\n \\\"schemaVersion\\\": 1,\\n \\\"id\\\": \\\"playwright\\\",\\n \\\"displayName\\\": {\\n \\\"es\\\": \\\"Playwright MCP\\\",\\n \\\"en\\\": \\\"Playwright MCP\\\"\\n },\\n \\\"description\\\": {\\n \\\"es\\\": \\\"Automatización de navegador mediante el servidor MCP oficial de Playwright.\\\",\\n \\\"en\\\": \\\"Browser automation through the official Playwright MCP server.\\\"\\n },\\n \\\"enabledByDefault\\\": false,\\n \\\"transport\\\": \\\"stdio\\\",\\n \\\"launch\\\": {\\n \\\"executable\\\": \\\"${nodeExecutable}\\\",\\n \\\"args\\\": [\\\"${serviceDir}/node_modules/@playwright/mcp/cli.js\\\"],\\n \\\"cwd\\\": \\\"${serviceDir}\\\",\\n \\\"env\\\": {},\\n \\\"handshakeTimeoutSeconds\\\": 30,\\n \\\"callTimeoutSeconds\\\": 120\\n }\\n}\\n\"\n}")
1105
1105
 
1106
1106
 
1107
1107
  def materialize_managed_services(services_root: Path | None = None) -> None:
@@ -1730,9 +1730,7 @@ try {
1730
1730
  playwright = require('playwright');
1731
1731
  } catch (e1) {
1732
1732
  try {
1733
- const path = require('path');
1734
- const home = process.env.HOME || process.env.USERPROFILE || '';
1735
- playwright = require(path.join(home, 'zerochat', 'services', 'playwright', 'node_modules', 'playwright'));
1733
+ playwright = require(process.env.ZEROCHAT_PLAYWRIGHT_MODULE);
1736
1734
  } catch (e2) {
1737
1735
  console.log(JSON.stringify({
1738
1736
  success: false,
@@ -1748,8 +1746,8 @@ try {
1748
1746
  const candidates = [
1749
1747
  {}, // 1. Playwright bundled Chromium
1750
1748
  { channel: 'chrome' }, // 2. System Google Chrome
1751
- { channel: 'msedge' }, // 3. System Microsoft Edge (Windows 10/11)
1752
- { channel: 'chromium' } // 4. System Chromium (/usr/bin/chromium)
1749
+ { channel: 'msedge' }, // 3. System Microsoft Edge
1750
+ { channel: 'chromium' } // 4. Playwright full Chromium, if only the headless shell is missing
1753
1751
  ];
1754
1752
  let lastErr = null;
1755
1753
  for (const cand of candidates) {
@@ -1841,6 +1839,7 @@ try {
1841
1839
  """
1842
1840
  proc = subprocess.Popen(
1843
1841
  [node, "-e", runner_js],
1842
+ env={**os.environ, "ZEROCHAT_PLAYWRIGHT_MODULE": str(playwright_service_module())},
1844
1843
  stdin=subprocess.PIPE,
1845
1844
  stdout=subprocess.PIPE,
1846
1845
  stderr=subprocess.PIPE,
@@ -1904,30 +1903,12 @@ def browser_action_availability() -> dict:
1904
1903
  """Comprueba requisitos locales sin lanzar Node.js ni Chromium."""
1905
1904
  if not shutil.which("node"):
1906
1905
  return {"available": False, "error": "Node.js is not installed or not found in system PATH."}
1907
- playwright_locations = (
1908
- Path.cwd() / "node_modules" / "playwright",
1909
- get_data_dir() / "services" / "playwright" / "node_modules" / "playwright"
1910
- )
1906
+ playwright_locations = (Path.cwd() / "node_modules" / "playwright", playwright_service_module())
1911
1907
  if not any(location.is_dir() for location in playwright_locations):
1912
1908
  return {"available": False, "error": "Playwright is not available."}
1913
- browser_commands = (
1914
- "google-chrome", "google-chrome-stable", "chromium", "chromium-browser",
1915
- "msedge", "msedge.exe", "chrome", "chrome.exe"
1916
- )
1917
- if sys.platform == "darwin":
1918
- playwright_cache = Path.home() / "Library" / "Caches" / "ms-playwright"
1919
- elif sys.platform.startswith("win"):
1920
- playwright_cache = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")) / "ms-playwright"
1921
- else:
1922
- playwright_cache = Path.home() / ".cache" / "ms-playwright"
1923
- try:
1924
- has_playwright_browser = playwright_cache.is_dir() and any(
1925
- child.is_dir() and child.name.startswith("chromium")
1926
- for child in playwright_cache.iterdir()
1927
- )
1928
- except OSError:
1929
- has_playwright_browser = False
1930
- if has_playwright_browser or any(shutil.which(command) for command in browser_commands):
1909
+ # El runner prueba las entradas de Chromium de Playwright y los canales chrome y msedge.
1910
+ browsers = system_browsers()
1911
+ if cached_engine_entries() & set(ENGINE_CACHE_ENTRIES["chromium"]) or browsers["chrome"] or browsers["msedge"]:
1931
1912
  return {"available": True}
1932
1913
  return {"available": False, "error": "Playwright Chromium is not installed."}
1933
1914
 
@@ -2113,6 +2094,355 @@ LOCAL_TOOL_HANDLERS = {
2113
2094
  "get_diagnostics": get_diagnostics,
2114
2095
  "browser_action": browser_action
2115
2096
  }
2097
+ # Configuración del navegador de Playwright MCP (sección Navegador de Ajustes).
2098
+ # Modo propio: un motor de Playwright con perfil persistente en ~/zerochat/playwright/profile/<motor>.
2099
+ # Modo del usuario: su Chrome, Edge o Chromium mediante la extensión de Playwright (--extension).
2100
+ BROWSER_MODES = ("own", "user")
2101
+ OWN_BROWSER_ENGINES = ("chromium", "firefox", "webkit")
2102
+ USER_BROWSERS = ("chrome", "msedge", "chromium")
2103
+ BROWSER_CONFIG_DEFAULTS = {"mode": "own", "engine": "chromium", "userBrowser": "chrome", "headless": True, "extensionToken": ""}
2104
+ # Identificador de la extensión de Playwright en la tienda de Chrome, que también usa Edge.
2105
+ PLAYWRIGHT_EXTENSION_ID = "mmlmfjhmonkocbjadbfplnigmagldckm"
2106
+ # Token que muestra la extensión: con él la conexión no pide aceptarla en el navegador.
2107
+ _EXTENSION_TOKEN = re.compile(r"^[A-Za-z0-9_-]{0,256}$")
2108
+ _BROWSER_CONFIG_CHOICES = {"mode": BROWSER_MODES, "engine": OWN_BROWSER_ENGINES, "userBrowser": USER_BROWSERS}
2109
+
2110
+
2111
+ def browser_config_file() -> Path:
2112
+ return get_data_dir() / "config" / "browser.json"
2113
+
2114
+
2115
+ def own_browser_profile_dir(engine: str) -> Path:
2116
+ return get_data_dir() / "playwright" / "profile" / engine
2117
+
2118
+
2119
+ def validate_browser_config(raw) -> dict:
2120
+ """Devuelve la configuración completa o lanza ValueError; los campos que faltan toman su valor por defecto."""
2121
+ if not isinstance(raw, dict):
2122
+ raise ValueError("La configuración del navegador debe ser un objeto")
2123
+ unknown = sorted(set(raw) - set(BROWSER_CONFIG_DEFAULTS))
2124
+ if unknown:
2125
+ raise ValueError(f"Campos desconocidos en la configuración del navegador: {', '.join(unknown)}")
2126
+ config = {**BROWSER_CONFIG_DEFAULTS, **raw}
2127
+ for key, choices in _BROWSER_CONFIG_CHOICES.items():
2128
+ if config[key] not in choices:
2129
+ raise ValueError(f"{key} debe ser uno de: {', '.join(choices)}")
2130
+ if not isinstance(config["headless"], bool):
2131
+ raise ValueError("headless debe ser un booleano")
2132
+ if not isinstance(config["extensionToken"], str) or not _EXTENSION_TOKEN.match(config["extensionToken"]):
2133
+ raise ValueError("extensionToken debe tener hasta 256 letras, cifras, guiones o guiones bajos")
2134
+ return config
2135
+
2136
+
2137
+ def load_browser_config() -> dict:
2138
+ path = browser_config_file()
2139
+ if not path.exists():
2140
+ return dict(BROWSER_CONFIG_DEFAULTS)
2141
+ try:
2142
+ return validate_browser_config(json.loads(path.read_text(encoding="utf-8")))
2143
+ except (OSError, ValueError) as err:
2144
+ log_event(f"[zerochat] Configuración del navegador no válida en {path}, se usan los valores por defecto: {err}")
2145
+ return dict(BROWSER_CONFIG_DEFAULTS)
2146
+
2147
+
2148
+ def save_browser_config(raw) -> dict:
2149
+ config = validate_browser_config(raw)
2150
+ path = browser_config_file()
2151
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
2152
+ # Guarda el token de la extensión: solo lo lee el usuario.
2153
+ _write_private_text(path, json.dumps(config, indent=2))
2154
+ return config
2155
+
2156
+
2157
+ def _playwright_active() -> bool:
2158
+ return any(server["id"] == "playwright" and server["status"] in ("running", "starting", "installing")
2159
+ for server in GLOBAL_MCP_MANAGER.list_servers())
2160
+
2161
+
2162
+ def clear_own_browser_data(engine) -> dict:
2163
+ """Borra el perfil persistente del motor (cookies, sesiones, almacenamiento de los sitios)."""
2164
+ if engine not in OWN_BROWSER_ENGINES:
2165
+ raise ValueError(f"engine debe ser uno de: {', '.join(OWN_BROWSER_ENGINES)}")
2166
+ # El navegador abierto tiene el perfil bloqueado y volvería a escribirlo.
2167
+ if _playwright_active():
2168
+ raise ValueError("Detén Playwright MCP antes de borrar los datos del navegador.")
2169
+ shutil.rmtree(own_browser_profile_dir(engine), ignore_errors=True)
2170
+ return {"cleared": engine}
2171
+
2172
+
2173
+ def chromium_install() -> tuple[str, Path] | None:
2174
+ """Chromium del sistema: ejecutable y carpeta de datos del usuario, o None si no está."""
2175
+ if sys.platform == "darwin":
2176
+ executable = "/Applications/Chromium.app/Contents/MacOS/Chromium"
2177
+ data_dir = Path.home() / "Library" / "Application Support" / "Chromium"
2178
+ elif sys.platform.startswith("win"):
2179
+ local = Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local")
2180
+ executable = str(local / "Chromium" / "Application" / "chrome.exe")
2181
+ data_dir = local / "Chromium" / "User Data"
2182
+ else:
2183
+ executable = shutil.which("chromium") or shutil.which("chromium-browser") or ""
2184
+ # El Chromium de snap guarda el perfil dentro de su confinamiento.
2185
+ snap = os.path.realpath(executable).startswith("/snap/")
2186
+ data_dir = Path.home() / ("snap/chromium/common/chromium" if snap else ".config/chromium")
2187
+ return (executable, data_dir) if executable and os.path.isfile(executable) else None
2188
+
2189
+
2190
+ def playwright_launch_args(config: dict) -> list[str]:
2191
+ """Argumentos de Playwright MCP para la configuración del navegador."""
2192
+ if config["mode"] == "user":
2193
+ # La extensión maneja el navegador abierto del usuario: siempre con ventana.
2194
+ if config["userBrowser"] != "chromium":
2195
+ return ["--extension", f"--browser={config['userBrowser']}"]
2196
+ # Chromium no es un canal de Playwright: se da su ejecutable y su perfil, con el canal chrome
2197
+ # para que no lo lance sin sandbox si no está abierto.
2198
+ install = chromium_install()
2199
+ if not install:
2200
+ raise RuntimeError("No se encuentra Chromium en el sistema: elige otro navegador en Ajustes › Navegador.")
2201
+ executable, data_dir = install
2202
+ return ["--extension", "--browser=chrome", f"--executable-path={executable}", f"--user-data-dir={data_dir}"]
2203
+ profile_dir = own_browser_profile_dir(config["engine"])
2204
+ for directory in (profile_dir.parent.parent, profile_dir.parent, profile_dir):
2205
+ directory.mkdir(mode=0o700, exist_ok=True)
2206
+ args = [f"--browser={config['engine']}", f"--user-data-dir={profile_dir}"]
2207
+ if config["headless"]:
2208
+ args.append("--headless")
2209
+ return args
2210
+
2211
+
2212
+ def playwright_launch_env(config: dict) -> dict[str, str]:
2213
+ """Entorno de Playwright MCP: el token de la extensión, que no aparece en la línea de órdenes ni en el log."""
2214
+ if config["mode"] == "user" and config["extensionToken"]:
2215
+ return {"PLAYWRIGHT_MCP_EXTENSION_TOKEN": config["extensionToken"]}
2216
+ return {}
2217
+
2218
+
2219
+ def validate_profile_browser(raw) -> dict:
2220
+ """Navegador que exige un perfil: el propio con un motor o el del usuario con uno de sus navegadores."""
2221
+ if isinstance(raw, dict) and raw.get("mode") == "own" and set(raw) == {"mode", "engine"} and raw["engine"] in OWN_BROWSER_ENGINES:
2222
+ return {"mode": "own", "engine": raw["engine"]}
2223
+ if isinstance(raw, dict) and raw.get("mode") == "user" and set(raw) == {"mode", "userBrowser"} and raw["userBrowser"] in USER_BROWSERS:
2224
+ return {"mode": "user", "userBrowser": raw["userBrowser"]}
2225
+ raise ValueError("browser debe ser {mode: own, engine} o {mode: user, userBrowser} con un motor o navegador admitido")
2226
+
2227
+
2228
+ def browser_choice(config: dict) -> dict:
2229
+ """Modo y navegador con los que arranca Playwright MCP, en la forma de validate_profile_browser."""
2230
+ if config["mode"] == "own":
2231
+ return {"mode": "own", "engine": config["engine"]}
2232
+ return {"mode": "user", "userBrowser": config["userBrowser"]}
2233
+
2234
+
2235
+ def _playwright_launch(browser: dict | None) -> tuple[list[str], dict[str, str], dict]:
2236
+ """La configuración de Ajustes › Navegador con el modo y el navegador que exija el perfil activo."""
2237
+ config = load_browser_config()
2238
+ if browser is not None:
2239
+ config = {**config, **validate_profile_browser(browser)}
2240
+ return playwright_launch_args(config), playwright_launch_env(config), browser_choice(config)
2241
+
2242
+
2243
+ # Argumentos, entorno y navegador elegido que añade ZeroChat al arrancar un servicio gestionado,
2244
+ # calculados en cada arranque.
2245
+ SERVICE_LAUNCH = {"playwright": _playwright_launch}
2246
+
2247
+
2248
+ # Motores propios de Playwright: cada uno se compone de las entradas de la caché que instala
2249
+ # "playwright install <motor>"; ffmpeg es común y no se quita.
2250
+ ENGINE_CACHE_ENTRIES = {"chromium": ("chromium", "chromium-headless-shell"), "firefox": ("firefox",), "webkit": ("webkit",)}
2251
+ ENGINE_INSTALL_TIMEOUT_SECONDS = 1200
2252
+ _DRY_RUN_ENTRY = re.compile(r"^(.+?) \(playwright (\S+) v\d+\)\n\s+Install location:\s+(.+)$", re.MULTILINE)
2253
+ _INSTALL_PERCENT = re.compile(r"(\d{1,3})% of ")
2254
+ _INSTALL_DOWNLOAD = re.compile(r"^Downloading (.+?) \(playwright ")
2255
+
2256
+
2257
+ def playwright_engine_cache() -> Path:
2258
+ """Caché de motores de Playwright sin lanzar Node: PLAYWRIGHT_BROWSERS_PATH o la ruta por defecto de la plataforma."""
2259
+ configured = os.environ.get("PLAYWRIGHT_BROWSERS_PATH", "").strip()
2260
+ if configured:
2261
+ return Path(configured).expanduser()
2262
+ if sys.platform == "darwin":
2263
+ return Path.home() / "Library" / "Caches" / "ms-playwright"
2264
+ if sys.platform.startswith("win"):
2265
+ return Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local") / "ms-playwright"
2266
+ return Path.home() / ".cache" / "ms-playwright"
2267
+
2268
+
2269
+ def cached_engine_entries() -> set[str]:
2270
+ """Entradas de ENGINE_CACHE_ENTRIES con alguna revisión completa en la caché; la revisión exacta de esta
2271
+ versión de Playwright solo la da BrowserEngines.list lanzando Node."""
2272
+ try:
2273
+ folders = [child.name for child in playwright_engine_cache().iterdir() if (child / "INSTALLATION_COMPLETE").is_file()]
2274
+ except OSError:
2275
+ return set()
2276
+ # Las carpetas se llaman como la entrada con guiones bajos y su revisión: chromium_headless_shell-1187.
2277
+ names = {name for entries in ENGINE_CACHE_ENTRIES.values() for name in entries}
2278
+ return {name for name in names if any(folder.startswith(name.replace("-", "_") + "-") for folder in folders)}
2279
+
2280
+
2281
+ def playwright_service_module() -> Path:
2282
+ return get_data_dir() / "services" / "playwright" / "node_modules" / "playwright"
2283
+
2284
+
2285
+ def playwright_service_cli() -> Path:
2286
+ return playwright_service_module() / "cli.js"
2287
+
2288
+
2289
+ def system_browsers() -> dict[str, bool]:
2290
+ """Chrome y Edge en las rutas donde los busca Playwright para --browser=chrome|msedge, Chromium donde lo
2291
+ busca chromium_install; Firefox solo se informa."""
2292
+ if sys.platform == "darwin":
2293
+ candidates = {
2294
+ "chrome": ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"],
2295
+ "msedge": ["/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"],
2296
+ "firefox": ["/Applications/Firefox.app/Contents/MacOS/firefox"],
2297
+ }
2298
+ elif sys.platform.startswith("win"):
2299
+ roots = [os.environ.get(name) for name in ("LOCALAPPDATA", "PROGRAMFILES", "PROGRAMFILES(X86)")]
2300
+ roots = [root for root in roots if root]
2301
+ candidates = {
2302
+ "chrome": [os.path.join(root, "Google", "Chrome", "Application", "chrome.exe") for root in roots],
2303
+ "msedge": [os.path.join(root, "Microsoft", "Edge", "Application", "msedge.exe") for root in roots],
2304
+ "firefox": [os.path.join(root, "Mozilla Firefox", "firefox.exe") for root in roots],
2305
+ }
2306
+ else:
2307
+ candidates = {
2308
+ "chrome": ["/opt/google/chrome/chrome"],
2309
+ "msedge": ["/opt/microsoft/msedge/msedge"],
2310
+ "firefox": [shutil.which("firefox") or ""],
2311
+ }
2312
+ found = {name: any(path and os.path.isfile(path) for path in paths) for name, paths in candidates.items()}
2313
+ return {**found, "chromium": chromium_install() is not None}
2314
+
2315
+
2316
+ def user_browser_data_dir(browser: str) -> Path | None:
2317
+ """Carpeta de datos del usuario de Chrome, Edge o Chromium, donde están sus perfiles."""
2318
+ if browser == "chromium":
2319
+ install = chromium_install()
2320
+ return install[1] if install else None
2321
+ if sys.platform == "darwin":
2322
+ root, paths = Path.home() / "Library" / "Application Support", {"chrome": "Google/Chrome", "msedge": "Microsoft Edge"}
2323
+ elif sys.platform.startswith("win"):
2324
+ root = Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local")
2325
+ paths = {"chrome": "Google/Chrome/User Data", "msedge": "Microsoft/Edge/User Data"}
2326
+ else:
2327
+ root, paths = Path.home() / ".config", {"chrome": "google-chrome", "msedge": "microsoft-edge"}
2328
+ return root / paths[browser]
2329
+
2330
+
2331
+ def user_browser_extensions() -> dict[str, bool]:
2332
+ """Si algún perfil de cada navegador del usuario tiene instalada la extensión de Playwright desde la tienda."""
2333
+ found = {}
2334
+ for browser in USER_BROWSERS:
2335
+ data_dir = user_browser_data_dir(browser)
2336
+ found[browser] = bool(data_dir) and any(data_dir.glob(f"*/Extensions/{PLAYWRIGHT_EXTENSION_ID}"))
2337
+ return found
2338
+
2339
+
2340
+ class BrowserEngines:
2341
+ """Lista, instala y quita los motores propios de Playwright en su caché, nunca en el sistema."""
2342
+
2343
+ def __init__(self):
2344
+ self._lock = threading.Lock()
2345
+ self._jobs: dict[str, dict] = {}
2346
+
2347
+ @staticmethod
2348
+ def _run_cli(cli: Path, *args: str, timeout: float) -> subprocess.CompletedProcess:
2349
+ return subprocess.run([shutil.which("node") or "node", str(cli), *args], cwd=cli.parent,
2350
+ capture_output=True, text=True, timeout=timeout)
2351
+
2352
+ def _locations(self, cli: Path) -> dict[str, tuple[str, Path]]:
2353
+ """Entradas de la caché de esta versión de Playwright: nombre -> (título con versión, ruta)."""
2354
+ names = [name for entries in ENGINE_CACHE_ENTRIES.values() for name in entries]
2355
+ result = self._run_cli(cli, "install", "--dry-run", *names, timeout=30)
2356
+ if result.returncode != 0:
2357
+ raise RuntimeError(f"Playwright no pudo listar sus navegadores: {(result.stderr or result.stdout).strip()}")
2358
+ return {name: (title, Path(location.strip())) for title, name, location in _DRY_RUN_ENTRY.findall(result.stdout)}
2359
+
2360
+ def list(self) -> dict:
2361
+ cli = playwright_service_cli()
2362
+ result = {"systemBrowsers": system_browsers(), "extensions": user_browser_extensions(), "engines": []}
2363
+ if not cli.is_file():
2364
+ return {**result, "available": False, "error": "Playwright MCP is not installed."}
2365
+ try:
2366
+ version = json.loads((cli.parent / "package.json").read_text(encoding="utf-8")).get("version")
2367
+ locations = self._locations(cli)
2368
+ except (OSError, json.JSONDecodeError, RuntimeError, subprocess.SubprocessError) as err:
2369
+ return {**result, "available": False, "error": str(err)}
2370
+ with self._lock:
2371
+ jobs = {engine: dict(job) for engine, job in self._jobs.items()}
2372
+ for engine, entries in ENGINE_CACHE_ENTRIES.items():
2373
+ title = locations.get(entries[0], (engine,))[0]
2374
+ installed = all(name in locations and (locations[name][1] / "INSTALLATION_COMPLETE").is_file() for name in entries)
2375
+ result["engines"].append({"id": engine, "title": title, "installed": installed, **jobs.get(engine, {})})
2376
+ return {**result, "available": True, "playwrightVersion": version}
2377
+
2378
+ def _check_engine(self, engine) -> Path:
2379
+ if engine not in ENGINE_CACHE_ENTRIES:
2380
+ raise ValueError(f"engine debe ser uno de: {', '.join(ENGINE_CACHE_ENTRIES)}")
2381
+ cli = playwright_service_cli()
2382
+ if not cli.is_file():
2383
+ raise ValueError("Playwright MCP no está instalado: arráncalo una vez desde Ajustes › MCP.")
2384
+ if self._jobs.get(engine, {}).get("status") == "installing":
2385
+ raise ValueError(f"{engine} ya se está instalando.")
2386
+ return cli
2387
+
2388
+ def install(self, engine) -> dict:
2389
+ with self._lock:
2390
+ cli = self._check_engine(engine)
2391
+ self._jobs[engine] = {"status": "installing", "percent": 0}
2392
+ threading.Thread(target=self._install, args=(cli, engine), daemon=True).start()
2393
+ return self.list()
2394
+
2395
+ def _install(self, cli: Path, engine: str):
2396
+ log_event(f"[zerochat] Instalando el motor {engine} de Playwright")
2397
+ lines = []
2398
+ try:
2399
+ proc = subprocess.Popen([shutil.which("node") or "node", str(cli), "install", engine], cwd=cli.parent,
2400
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
2401
+ timer = threading.Timer(ENGINE_INSTALL_TIMEOUT_SECONDS, proc.kill)
2402
+ timer.start()
2403
+ try:
2404
+ for line in proc.stdout:
2405
+ lines = (lines + [line.rstrip()])[-20:]
2406
+ percent, download = _INSTALL_PERCENT.search(line), _INSTALL_DOWNLOAD.match(line)
2407
+ with self._lock:
2408
+ if download:
2409
+ self._jobs[engine].update(download=download.group(1), percent=0)
2410
+ elif percent:
2411
+ self._jobs[engine]["percent"] = min(100, int(percent.group(1)))
2412
+ code = proc.wait()
2413
+ finally:
2414
+ timer.cancel()
2415
+ if code != 0:
2416
+ raise RuntimeError("\n".join(lines[-5:]) or f"código {code}")
2417
+ with self._lock:
2418
+ self._jobs.pop(engine, None)
2419
+ log_event(f"[zerochat] Motor {engine} de Playwright instalado")
2420
+ except (OSError, RuntimeError, subprocess.SubprocessError) as err:
2421
+ with self._lock:
2422
+ self._jobs[engine] = {"status": "error", "error": format_log_error(str(err), 500)}
2423
+ log_event(f"[zerochat] Fallo instalando el motor {engine} de Playwright: {format_log_error(str(err))}")
2424
+
2425
+ def remove(self, engine) -> dict:
2426
+ with self._lock:
2427
+ cli = self._check_engine(engine)
2428
+ # Borrar un motor en uso deja al navegador abierto sin sus ficheros.
2429
+ if _playwright_active():
2430
+ raise ValueError("Detén Playwright MCP antes de quitar un motor.")
2431
+ locations = self._locations(cli)
2432
+ for name in ENGINE_CACHE_ENTRIES[engine]:
2433
+ if name in locations:
2434
+ shutil.rmtree(locations[name][1], ignore_errors=True)
2435
+ self._jobs.pop(engine, None)
2436
+ result = self.list()
2437
+ # La configuración no se queda con un motor quitado si hay otro instalado.
2438
+ config = load_browser_config()
2439
+ replacement = next((item["id"] for item in result["engines"] if item["installed"]), None)
2440
+ if config["engine"] == engine and replacement:
2441
+ save_browser_config({**config, "engine": replacement})
2442
+ return result
2443
+
2444
+
2445
+ BROWSER_ENGINES = BrowserEngines()
2116
2446
  # ==============================================================================
2117
2447
  # Servidor HTTP JSON-RPC 2.0 y SSE con Autenticación por Token
2118
2448
  # ==============================================================================
@@ -2482,6 +2812,8 @@ class McpServiceManager:
2482
2812
  self.clients: dict[str, StdioMcpClient] = {}
2483
2813
  self.states: dict[str, str] = {}
2484
2814
  self.errors: dict[str, str] = {}
2815
+ # Modo y navegador con los que arrancó cada servicio de SERVICE_LAUNCH.
2816
+ self.browsers: dict[str, dict] = {}
2485
2817
  self._lock = threading.Lock()
2486
2818
  materialize_managed_services(self.services_root)
2487
2819
  self.services = self._load_services()
@@ -2546,6 +2878,7 @@ class McpServiceManager:
2546
2878
  "options": server.get("options", []),
2547
2879
  "userOptions": pref.get("options", {}),
2548
2880
  "oauthUrl": client.oauth_url if client else None,
2881
+ "browser": self.browsers.get(server_id) if running else None,
2549
2882
  })
2550
2883
 
2551
2884
  return result
@@ -2652,7 +2985,9 @@ class McpServiceManager:
2652
2985
  if int(version_match.group(1)) < required_node_major:
2653
2986
  raise RuntimeError(f"{server_id} requiere Node.js {required_node_major}+; se detectó {detected}")
2654
2987
 
2655
- def start(self, server_id: str) -> list[dict]:
2988
+ def start(self, server_id: str, remember: bool, browser: dict | None = None) -> list[dict]:
2989
+ """Arranca un servicio; con remember lo guarda como activo para ofrecerlo en la siguiente sesión.
2990
+ `browser` es el navegador que exige el perfil activo, solo para Playwright MCP."""
2656
2991
  with self._lock:
2657
2992
  self.services = self._load_services()
2658
2993
  server = self.services.get(server_id)
@@ -2682,10 +3017,14 @@ class McpServiceManager:
2682
3017
  launch = server.get("launch", {})
2683
3018
  command = self._expand(launch.get("executable", sys.executable), values)
2684
3019
  args = [self._expand(arg, values) for arg in launch.get("args", []) + option_args]
2685
-
2686
3020
  env = os.environ.copy()
2687
3021
  for k, v in launch.get("env", {}).items():
2688
3022
  env[k] = self._expand(v, values)
3023
+ self.browsers.pop(server_id, None)
3024
+ if server_id in SERVICE_LAUNCH:
3025
+ extra_args, extra_env, self.browsers[server_id] = SERVICE_LAUNCH[server_id](browser)
3026
+ args += extra_args
3027
+ env.update(extra_env)
2689
3028
 
2690
3029
  client = StdioMcpClient(
2691
3030
  command, args, str(service_dir), env,
@@ -2697,9 +3036,10 @@ class McpServiceManager:
2697
3036
  self.clients[server_id] = client
2698
3037
  client.start(int(launch.get("handshakeTimeoutSeconds", 15)))
2699
3038
  self.states[server_id] = "running"
2700
- entry = self.preferences.setdefault(server_id, {})
2701
- entry["enabled"] = True
2702
- self._save_preferences()
3039
+ if remember:
3040
+ entry = self.preferences.setdefault(server_id, {})
3041
+ entry["enabled"] = True
3042
+ self._save_preferences()
2703
3043
  self._trace(server_id, "started successfully")
2704
3044
  except Exception as exc:
2705
3045
  self.states[server_id] = "error"
@@ -2804,7 +3144,8 @@ HEARTBEAT_WATCHDOG_STOP = threading.Event()
2804
3144
  _SERVER_SHUTTING_DOWN = threading.Event()
2805
3145
  # Si el servidor se detiene al cerrar el navegador, cerrar la pestaña detiene también los comandos.
2806
3146
  EXIT_ON_CLOSE = False
2807
- QUIET_RPC_METHODS = frozenset({"zerochat/jobs/get", "zerochat/jobs/log", "zerochat/jobs/list"})
3147
+ # zerochat/run/progress ya se registra con su propio texto en dispatch_run.
3148
+ QUIET_RPC_METHODS = frozenset({"zerochat/jobs/get", "zerochat/jobs/log", "zerochat/jobs/list", "zerochat/run/progress"})
2808
3149
 
2809
3150
 
2810
3151
  def mark_browser_active():
@@ -3277,9 +3618,10 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
3277
3618
  self._log_req("POST", safe_path)
3278
3619
  self._reject(400, {"error": "Invalid Content-Length"}, safe_path, t0, "Content-Length negativo")
3279
3620
  return None
3280
- if content_len > MAX_HTTP_BODY_BYTES:
3621
+ body_limit = http_body_limit()
3622
+ if content_len > body_limit:
3281
3623
  self._log_req("POST", safe_path)
3282
- if content_len <= MAX_HTTP_BODY_BYTES * 2:
3624
+ if content_len <= body_limit * 2:
3283
3625
  try:
3284
3626
  self.rfile.read(content_len)
3285
3627
  except (OSError, ValueError):
@@ -3296,6 +3638,11 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
3296
3638
  self._reject(400, {"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": f"Parse error: {err}"}},
3297
3639
  safe_path, t0, f"Error parseando JSON: {err}")
3298
3640
  return None
3641
+ # El tope ampliado de http_body_limit es solo para la exportación de --prompt --export.
3642
+ if content_len > MAX_HTTP_BODY_BYTES and not (isinstance(req, dict) and req.get("method") == "zerochat/run/export"):
3643
+ self._log_req("POST", safe_path)
3644
+ self._reject(413, {"error": "Request body too large"}, safe_path, t0, "Cuerpo HTTP excede el límite")
3645
+ return None
3299
3646
 
3300
3647
  rpc_error = self._validate_rpc(req)
3301
3648
  if rpc_error:
@@ -3440,10 +3787,14 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
3440
3787
  return None, {"code": -32603, "message": f"Error en transporte web: {exc}"}, str(exc)
3441
3788
  if method.startswith("zerochat/jobs/"):
3442
3789
  return self._dispatch_jobs(method, params)
3790
+ if method.startswith("zerochat/run/"):
3791
+ return dispatch_run(method, params)
3443
3792
  if method == "zerochat/external/status":
3444
3793
  return {"host": "running", "version": VERSION, "servers": GLOBAL_MCP_MANAGER.list_servers()}, None, ""
3445
3794
  if method in ("zerochat/external/servers/start", "zerochat/external/servers/stop", "zerochat/external/servers/configure"):
3446
3795
  return self._dispatch_server_control(method, params)
3796
+ if method.startswith("zerochat/browser/"):
3797
+ return self._dispatch_browser(method, params)
3447
3798
  return None, {"code": -32601, "message": f"Método '{method}' no soportado."}, ""
3448
3799
 
3449
3800
  @staticmethod
@@ -3475,17 +3826,49 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
3475
3826
  return job_details(job), None, ""
3476
3827
  return None, {"code": -32601, "message": f"Método '{method}' no soportado."}, ""
3477
3828
 
3829
+ @staticmethod
3830
+ def _dispatch_browser(method: str, params: dict) -> tuple[dict | None, dict | None, str]:
3831
+ """Configuración, datos y motores propios de Playwright para la sección Navegador de Ajustes."""
3832
+ try:
3833
+ if method == "zerochat/browser/config/get":
3834
+ return load_browser_config(), None, ""
3835
+ if method == "zerochat/browser/config/set":
3836
+ return save_browser_config(params.get("config")), None, ""
3837
+ if method == "zerochat/browser/data/clear":
3838
+ return clear_own_browser_data(params.get("engine")), None, ""
3839
+ if method == "zerochat/browser/engines/list":
3840
+ return BROWSER_ENGINES.list(), None, ""
3841
+ if method == "zerochat/browser/engines/install":
3842
+ return BROWSER_ENGINES.install(params.get("engine")), None, ""
3843
+ if method == "zerochat/browser/engines/remove":
3844
+ return BROWSER_ENGINES.remove(params.get("engine")), None, ""
3845
+ except (ValueError, RuntimeError, OSError, subprocess.SubprocessError) as exc:
3846
+ return None, {"code": -32602, "message": str(exc)}, ""
3847
+ return None, {"code": -32601, "message": f"Método '{method}' no soportado."}, ""
3848
+
3478
3849
  @staticmethod
3479
3850
  def _dispatch_server_control(method: str, params: dict) -> tuple[dict | None, dict | None, str]:
3480
3851
  server_id = params.get("serverId")
3481
3852
  opts = params.get("options", {})
3853
+ remember = params.get("remember")
3854
+ browser = params.get("browser")
3482
3855
  if not isinstance(server_id, str) or not server_id:
3483
3856
  return None, {"code": -32602, "message": "serverId debe ser un texto no vacío."}, ""
3484
3857
  if not isinstance(opts, dict):
3485
3858
  return None, {"code": -32602, "message": "options debe ser un objeto."}, ""
3859
+ # Al arrancar, el cliente decide siempre si el servicio se recuerda para la siguiente sesión.
3860
+ if method.endswith("/start") and not isinstance(remember, bool):
3861
+ return None, {"code": -32602, "message": "remember es obligatorio al arrancar y debe ser un booleano."}, ""
3862
+ if method.endswith("/start") and browser is not None:
3863
+ if server_id not in SERVICE_LAUNCH:
3864
+ return None, {"code": -32602, "message": f"{server_id} no admite elegir navegador."}, ""
3865
+ try:
3866
+ browser = validate_profile_browser(browser)
3867
+ except ValueError as exc:
3868
+ return None, {"code": -32602, "message": str(exc)}, ""
3486
3869
  try:
3487
3870
  if method.endswith("/start"):
3488
- servers = GLOBAL_MCP_MANAGER.start(server_id)
3871
+ servers = GLOBAL_MCP_MANAGER.start(server_id, remember, browser)
3489
3872
  elif method.endswith("/stop"):
3490
3873
  servers = GLOBAL_MCP_MANAGER.stop(server_id)
3491
3874
  else:
@@ -3719,6 +4102,234 @@ def launch_browser(url: str) -> bool:
3719
4102
 
3720
4103
 
3721
4104
 
4105
+
4106
+
4107
+ def default_browser_executable() -> str | None:
4108
+ """Ejecutable del navegador predeterminado en Linux (xdg-settings y su fichero .desktop), o None."""
4109
+ if not sys.platform.startswith("linux") or is_termux_environment() or not shutil.which("xdg-settings"):
4110
+ return None
4111
+ try:
4112
+ desktop = subprocess.run(["xdg-settings", "get", "default-web-browser"], capture_output=True, text=True, timeout=5).stdout.strip()
4113
+ except (OSError, subprocess.SubprocessError):
4114
+ return None
4115
+ if not desktop.endswith(".desktop") or "/" in desktop:
4116
+ return None
4117
+ data_dirs = [os.environ.get("XDG_DATA_HOME") or str(Path.home() / ".local" / "share")]
4118
+ data_dirs += (os.environ.get("XDG_DATA_DIRS") or "/usr/local/share:/usr/share").split(":")
4119
+ for data_dir in data_dirs:
4120
+ entry = Path(data_dir) / "applications" / desktop
4121
+ if not entry.is_file():
4122
+ continue
4123
+ try:
4124
+ lines = entry.read_text(encoding="utf-8", errors="replace").splitlines()
4125
+ except OSError:
4126
+ return None
4127
+ exec_line = next((line[5:] for line in lines if line.startswith("Exec=")), "")
4128
+ try:
4129
+ tokens = [token for token in shlex.split(exec_line) if token != "env" and "=" not in token]
4130
+ except ValueError:
4131
+ return None
4132
+ # Flatpak o Snap envuelven el navegador: no admiten sus opciones directamente.
4133
+ if not tokens or Path(tokens[0]).name in ("flatpak", "snap"):
4134
+ return None
4135
+ return shutil.which(tokens[0])
4136
+ return None
4137
+
4138
+
4139
+ def launch_headless_browser(url: str) -> subprocess.Popen | None:
4140
+ """
4141
+ Abre la URL en el navegador predeterminado con --headless y el perfil normal del usuario.
4142
+ Si el navegador ya está abierto, este suele recibir la URL como una pestaña visible.
4143
+ Devuelve el proceso lanzado, o None si no se pudo y hay que abrirlo de la forma normal.
4144
+ """
4145
+ executable = default_browser_executable()
4146
+ if not executable:
4147
+ log_event("No se pudo identificar el navegador predeterminado para abrirlo sin ventana; se abrirá de la forma normal.")
4148
+ return None
4149
+ log_event(f"Abriendo {Path(executable).name} sin ventana en la interfaz configurada...")
4150
+ try:
4151
+ return subprocess.Popen(
4152
+ [executable, "--headless", url],
4153
+ stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
4154
+ start_new_session=True,
4155
+ )
4156
+ except OSError as error:
4157
+ log_event(f"No se pudo abrir {executable} sin ventana ({error}); se abrirá de la forma normal.")
4158
+ return None
4159
+ # ==============================================================================
4160
+ # Modo ejecución desatendida (zerochat.py --prompt)
4161
+ # ==============================================================================
4162
+ # El servidor arranca y abre el navegador como siempre, con run=<id> en el fragmento: la interfaz
4163
+ # pide el prompt, informa del progreso y devuelve el resultado por JSON-RPC zerochat/run/*.
4164
+
4165
+ # Plazo total y sin progreso de la interfaz; las variables de entorno solo las usan las pruebas.
4166
+ RUN_TOTAL_TIMEOUT = float(os.environ.get("ZEROCHAT_RUN_TIMEOUT", "900"))
4167
+ RUN_IDLE_SECONDS = float(os.environ.get("ZEROCHAT_RUN_IDLE_SECONDS", "180"))
4168
+ RUN_POLL_SECONDS = 0.2
4169
+ RUN_PROGRESS_TEXT_MAX = 500
4170
+ RUN_PROGRESS_KIND_PATTERN = re.compile(r"[a-z_]{1,32}")
4171
+ RUN_EXIT_DONE = 0
4172
+ RUN_EXIT_ERROR = 1
4173
+ RUN_EXIT_TIMEOUT = 2
4174
+ RUN_EXIT_SETUP = 3
4175
+ # Argumentos no válidos, en cualquier modo: argparse usaría 2, que con --prompt es plazo agotado.
4176
+ RUN_EXIT_ARGS = 4
4177
+ # Formato de --export según la extensión del fichero; la interfaz genera el contenido con ChatExport.
4178
+ RUN_EXPORT_FORMATS = {".md": "markdown", ".json": "json", ".html": "html"}
4179
+ # Cuerpo HTTP admitido mientras se espera la exportación: el JSON y el HTML llevan las imágenes incrustadas.
4180
+ RUN_EXPORT_MAX_BODY_BYTES = 64 * 1024 * 1024
4181
+
4182
+
4183
+ class RunSession:
4184
+ """Estado de la ejecución: prompt, opciones, último progreso, exportación, resultado y motivo de un final sin él."""
4185
+ def __init__(self, prompt: str, profile: str | None = None, export_path: Path | None = None, keep_page: bool = False,
4186
+ allow_dangerous_tools: bool = False):
4187
+ self.run_id = secrets.token_urlsafe(24)
4188
+ self.prompt = prompt
4189
+ # Perfil (nombre o id) que la interfaz usa solo en esta ejecución; None, el activo.
4190
+ self.profile = profile
4191
+ # Fichero donde se escribe la conversación exportada (formato por su extensión); None, sin exportar.
4192
+ self.export_path = export_path
4193
+ self.exported = False
4194
+ self.export_error: str | None = None
4195
+ # La interfaz no cierra la página al terminar, para ver la conversación (sin --headless).
4196
+ self.keep_page = keep_page
4197
+ # La interfaz aprueba también write_file, edit_file y run_command, que si no deniega.
4198
+ self.allow_dangerous_tools = allow_dangerous_tools
4199
+ self.lock = threading.Lock()
4200
+ self.last_activity = time.monotonic()
4201
+ self.prompt_delivered = False
4202
+ self.result: dict | None = None
4203
+ self.stop_reason: str | None = None
4204
+ self.finished = threading.Event()
4205
+
4206
+ def touch(self):
4207
+ with self.lock:
4208
+ self.last_activity = time.monotonic()
4209
+
4210
+ def idle_seconds(self) -> float:
4211
+ with self.lock:
4212
+ return time.monotonic() - self.last_activity
4213
+
4214
+
4215
+ RUN_SESSION: RunSession | None = None
4216
+
4217
+
4218
+ def http_body_limit() -> int:
4219
+ """Tope del cuerpo que se lee: el general, o el de la exportación mientras se espera (ff-server
4220
+ rechaza después cualquier otro método que supere el general)."""
4221
+ session = RUN_SESSION
4222
+ if session is not None and session.export_path is not None and not session.exported:
4223
+ return RUN_EXPORT_MAX_BODY_BYTES
4224
+ return MAX_HTTP_BODY_BYTES
4225
+
4226
+
4227
+ def dispatch_run(method: str, params: dict) -> tuple[dict | None, dict | None, str]:
4228
+ """Métodos zerochat/run/* que usa la interfaz abierta por el modo ejecución."""
4229
+ session = RUN_SESSION
4230
+ if session is None:
4231
+ return None, {"code": -32601, "message": "El servidor no está en modo ejecución."}, ""
4232
+ run_id = params.get("run_id")
4233
+ if not isinstance(run_id, str) or not hmac.compare_digest(run_id, session.run_id):
4234
+ return None, {"code": -32602, "message": "run_id no válido."}, ""
4235
+ if method == "zerochat/run/prompt":
4236
+ with session.lock:
4237
+ if session.prompt_delivered:
4238
+ return None, {"code": -32602, "message": "El prompt ya se entregó."}, ""
4239
+ session.prompt_delivered = True
4240
+ session.touch()
4241
+ export_format = RUN_EXPORT_FORMATS[session.export_path.suffix.lower()] if session.export_path else None
4242
+ return {"prompt": session.prompt, "profile": session.profile, "export": export_format, "keep_page": session.keep_page,
4243
+ "allow_dangerous_tools": session.allow_dangerous_tools}, None, ""
4244
+ if method == "zerochat/run/progress":
4245
+ kind = params.get("kind")
4246
+ text = params.get("text", "")
4247
+ if not isinstance(kind, str) or not RUN_PROGRESS_KIND_PATTERN.fullmatch(kind) or not isinstance(text, str):
4248
+ return None, {"code": -32602, "message": "Progreso no válido."}, ""
4249
+ session.touch()
4250
+ if len(text) > RUN_PROGRESS_TEXT_MAX:
4251
+ text = text[:RUN_PROGRESS_TEXT_MAX] + "…"
4252
+ log_event(f"[run] {kind}{': ' + text if text else ''}")
4253
+ return {"ok": True}, None, ""
4254
+ if method == "zerochat/run/export":
4255
+ content = params.get("content")
4256
+ if session.export_path is None or not isinstance(content, str):
4257
+ return None, {"code": -32602, "message": "Exportación no pedida o sin contenido."}, ""
4258
+ with session.lock:
4259
+ if session.exported:
4260
+ return None, {"code": -32602, "message": "La exportación ya se recibió."}, ""
4261
+ try:
4262
+ session.export_path.write_text(content, encoding="utf-8")
4263
+ except OSError as exc:
4264
+ session.export_error = f"No se pudo escribir {session.export_path}: {exc}"
4265
+ return None, {"code": -32603, "message": session.export_error}, ""
4266
+ session.exported = True
4267
+ session.touch()
4268
+ log_event(f"[run] Conversación exportada en {session.export_path}")
4269
+ return {"ok": True}, None, ""
4270
+ if method == "zerochat/run/result":
4271
+ text = params.get("text")
4272
+ error = params.get("error")
4273
+ if not ((isinstance(text, str) and error is None) or (isinstance(error, str) and error and text is None)):
4274
+ return None, {"code": -32602, "message": "El resultado lleva text o error."}, ""
4275
+ with session.lock:
4276
+ if session.result is not None:
4277
+ return None, {"code": -32602, "message": "El resultado ya se recibió."}, ""
4278
+ session.result = {"text": text} if error is None else {"error": error}
4279
+ session.finished.set()
4280
+ return {"ok": True}, None, ""
4281
+ return None, {"code": -32601, "message": f"Método '{method}' no soportado."}, ""
4282
+
4283
+
4284
+ def run_supervisor(server: ThreadingHTTPServer, session: RunSession):
4285
+ """Detiene el servidor al recibir el resultado o al agotar el plazo total o el de inactividad."""
4286
+ deadline = time.monotonic() + RUN_TOTAL_TIMEOUT
4287
+ while not session.finished.wait(RUN_POLL_SECONDS):
4288
+ # El latido o una señal ya detienen el servidor: el resultado queda vacío.
4289
+ if _SERVER_SHUTTING_DOWN.is_set():
4290
+ return
4291
+ if time.monotonic() >= deadline:
4292
+ session.stop_reason = f"Plazo total agotado ({RUN_TOTAL_TIMEOUT:.0f}s)."
4293
+ break
4294
+ if session.idle_seconds() > RUN_IDLE_SECONDS:
4295
+ session.stop_reason = f"Sin progreso de la interfaz durante {RUN_IDLE_SECONDS:.0f}s."
4296
+ break
4297
+ stop_zerochat_server(server)
4298
+
4299
+
4300
+ def run_error(message: str):
4301
+ """Motivo de un final sin respuesta: va a stderr aunque el registro esté desactivado (--log off)."""
4302
+ print(timestamp_message(f"[run] {message}"), file=sys.stderr, flush=True)
4303
+
4304
+
4305
+ def run_outcome(session: RunSession) -> tuple[int, str | None]:
4306
+ """
4307
+ Código de salida y respuesta final (solo si terminó bien), con el motivo de un fallo en stderr;
4308
+ una exportación pedida que no se escribió convierte un final correcto en error sin quitar la respuesta.
4309
+ """
4310
+ if session.export_path and not session.exported:
4311
+ run_error(session.export_error or f"La interfaz no envió la conversación para {session.export_path}.")
4312
+ if session.result is None:
4313
+ run_error(session.stop_reason or "El navegador se cerró o la ejecución se interrumpió sin resultado.")
4314
+ return RUN_EXIT_TIMEOUT, None
4315
+ if "error" in session.result:
4316
+ run_error(f"Error de generación: {session.result['error']}")
4317
+ return RUN_EXIT_ERROR, None
4318
+ log_event("[run] Ejecución terminada.")
4319
+ if session.export_path and not session.exported:
4320
+ return RUN_EXIT_ERROR, session.result["text"]
4321
+ return RUN_EXIT_DONE, session.result["text"]
4322
+
4323
+
4324
+ def fall_back_when_headless_exits(proc: subprocess.Popen, url: str):
4325
+ """
4326
+ Un navegador sin ventana termina enseguida si su perfil ya está abierto (Firefox avisa de que
4327
+ ya está en marcha): si acaba antes de que conecte la interfaz, se abre la pestaña normal.
4328
+ """
4329
+ proc.wait()
4330
+ if not HEARTBEAT_INITIALIZED and not _SERVER_SHUTTING_DOWN.is_set():
4331
+ log_event("El navegador sin ventana terminó sin abrir la interfaz (¿ya estaba abierto?); se abre una pestaña de la forma normal.")
4332
+ launch_browser(url)
3722
4333
  # ==============================================================================
3723
4334
  # Punto de Entrada Principal (CLI)
3724
4335
  # ==============================================================================
@@ -3744,26 +4355,71 @@ def remote_exposure_warning(host: str) -> str | None:
3744
4355
  )
3745
4356
 
3746
4357
 
4358
+ class ZeroChatArgumentParser(argparse.ArgumentParser):
4359
+ """Los errores de argumentos salen con RUN_EXIT_ARGS para no confundirse con los códigos de --prompt."""
4360
+ def error(self, message):
4361
+ self.print_usage(sys.stderr)
4362
+ self.exit(RUN_EXIT_ARGS, f"{self.prog}: error: {message}\n")
4363
+
4364
+
3747
4365
  def main():
3748
4366
  global ACTIVE_PORT, ACTIVE_HOST, SESSION_TOKEN, CONSOLE_CONTROL, EXIT_ON_CLOSE
3749
- global TOOL_AUTH_KEY, TOOL_AUTH_SESSION_ID, TOOL_AUTH_NONCES
4367
+ global TOOL_AUTH_KEY, TOOL_AUTH_SESSION_ID, TOOL_AUTH_NONCES, RUN_SESSION
3750
4368
 
3751
- parser = argparse.ArgumentParser(description=f"ZeroChat Local Server v{VERSION}")
4369
+ parser = ZeroChatArgumentParser(description=f"ZeroChat Local Server v{VERSION}")
3752
4370
  # Desde Python 3.14 argparse colorea la ayuda con códigos ANSI; la consola la muestra en texto plano.
3753
4371
  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})")
4372
+ 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
4373
  parser.add_argument("--host", default=os.environ.get("ZEROCHAT_HOST", DEFAULT_HOST), help=f"Host de escucha (default: {DEFAULT_HOST})")
3756
4374
  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
4375
  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")
4376
+ parser.add_argument("--keep-server", action="store_true", help="Servidor: seguir en marcha al cerrar el navegador (no se combina con --prompt)")
3759
4377
  parser.add_argument("--no-venv", action="store_true", help="Omitir la comprobación/creación de ~/zerochat/.venv")
3760
4378
  parser.add_argument("--test", action="store_true", help="Ejecutar autocomprobación interna de herramientas")
4379
+ 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)")
4380
+ parser.add_argument("--profile", default=None, help="Con --prompt, perfil (nombre o id) que se usa solo en esta ejecución, sin cambiar el activo")
4381
+ parser.add_argument("--log", choices=("on", "off"), default=None, help="Mostrar la cabecera y el registro del servidor (default: on; off con --prompt)")
4382
+ 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)})")
4383
+ 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)")
4384
+ 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")
4385
+ 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
4386
  parser.add_argument("--version", action="version", version=f"ZeroChat {VERSION}")
3762
4387
  args = parser.parse_args()
3763
4388
 
3764
4389
  # Los avisos no persisten entre ejecuciones del servidor.
3765
4390
  reset_notices()
3766
4391
 
4392
+ # Modo ejecución: stdout queda solo para la respuesta final; todo el registro va a stderr.
4393
+ result_out = sys.stdout
4394
+ 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):
4395
+ parser.error("--headless, --keep-page, --allow-dangerous-tools, --profile y --export solo se usan con --prompt")
4396
+ if args.prompt is not None:
4397
+ if not args.prompt.strip():
4398
+ parser.error("el prompt está vacío")
4399
+ if args.no_browser:
4400
+ parser.error("--prompt necesita abrir el navegador: no se combina con --no-browser")
4401
+ if args.keep_server:
4402
+ parser.error("--prompt termina al devolver el resultado: no se combina con --keep-server (para no cerrar la página, --keep-page)")
4403
+ if args.profile is not None and not args.profile.strip():
4404
+ parser.error("--profile no puede estar vacío")
4405
+ if args.keep_page and args.headless:
4406
+ parser.error("--keep-page necesita la ventana del navegador: no se combina con --headless")
4407
+ export_path = None
4408
+ if args.export is not None:
4409
+ export_path = Path(args.export).expanduser().absolute()
4410
+ if export_path.suffix.lower() not in RUN_EXPORT_FORMATS:
4411
+ parser.error(f"--export necesita una de estas extensiones: {', '.join(RUN_EXPORT_FORMATS)}")
4412
+ if not export_path.parent.is_dir():
4413
+ parser.error(f"--export: no existe el directorio {export_path.parent}")
4414
+ RUN_SESSION = RunSession(args.prompt.strip(), args.profile.strip() if args.profile else None, export_path, args.keep_page, args.allow_dangerous_tools)
4415
+ # Todo el registro pasa por stdout: con --prompt va a stderr para dejar stdout a la respuesta,
4416
+ # y con --log off se descarta.
4417
+ log_enabled = args.log == "on" if args.log else RUN_SESSION is None
4418
+ if not log_enabled:
4419
+ sys.stdout = open(os.devnull, "w", encoding="utf-8")
4420
+ elif RUN_SESSION:
4421
+ sys.stdout = sys.stderr
4422
+
3767
4423
  if args.test:
3768
4424
  print(timestamp_message(f"TEST list_directory {'ok' if json.loads(list_directory('.'))['success'] else 'error'}"))
3769
4425
  print(timestamp_message(f"TEST read_file {'ok' if json.loads(read_file('package.json', max_lines=5))['success'] else 'error'}"))
@@ -3796,7 +4452,7 @@ def main():
3796
4452
  if not is_dev:
3797
4453
  threading.Thread(target=check_version, daemon=True).start()
3798
4454
 
3799
- ACTIVE_PORT = args.port
4455
+ ACTIVE_PORT = args.port if args.port is not None else int(os.environ.get("ZEROCHAT_PORT", DEFAULT_PORT))
3800
4456
  ACTIVE_HOST = args.host
3801
4457
  SESSION_TOKEN = get_daily_token()
3802
4458
  # A restart deliberately invalidates every browser-side signing credential.
@@ -3805,7 +4461,25 @@ def main():
3805
4461
  with TOOL_AUTH_NONCES_LOCK:
3806
4462
  TOOL_AUTH_NONCES = {}
3807
4463
 
3808
- server = ThreadingHTTPServer((ACTIVE_HOST, ACTIVE_PORT), ZeroChatServerHandler)
4464
+ # La interfaz local de desarrollo tiene el puerto en su origen: en otro no vería los datos guardados.
4465
+ local_ui = static_root is not None and not args.ui_url
4466
+ try:
4467
+ server = ThreadingHTTPServer((ACTIVE_HOST, ACTIVE_PORT), ZeroChatServerHandler)
4468
+ except OSError as exc:
4469
+ if not RUN_SESSION:
4470
+ raise
4471
+ if args.port is not None or local_ui:
4472
+ hint = "" if args.port is not None else " (en desarrollo la interfaz local necesita ese puerto: cierra el zerochat que lo usa)"
4473
+ run_error(f"No se pudo escuchar en {ACTIVE_HOST}:{ACTIVE_PORT}: {exc}{hint}")
4474
+ sys.exit(RUN_EXIT_SETUP)
4475
+ # Otro zerochat (u otro programa) usa el puerto por defecto: la ejecución usa uno libre.
4476
+ try:
4477
+ server = ThreadingHTTPServer((ACTIVE_HOST, 0), ZeroChatServerHandler)
4478
+ except OSError as retry_exc:
4479
+ run_error(f"No se pudo escuchar en {ACTIVE_HOST}: {retry_exc}")
4480
+ sys.exit(RUN_EXIT_SETUP)
4481
+ log_event(f"El puerto {ACTIVE_PORT} está ocupado; la ejecución usa el {server.server_address[1]}.")
4482
+ ACTIVE_PORT = server.server_address[1]
3809
4483
  exposure_warning = remote_exposure_warning(ACTIVE_HOST)
3810
4484
  if exposure_warning:
3811
4485
  add_notice(exposure_warning)
@@ -3818,8 +4492,12 @@ def main():
3818
4492
  ui_url = DEFAULT_UI_URL
3819
4493
 
3820
4494
  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
4495
+ fragment = {'token': SESSION_TOKEN, 'host': browser_host, 'port': ACTIVE_PORT}
4496
+ if RUN_SESSION:
4497
+ fragment['run'] = RUN_SESSION.run_id
4498
+ target_url = f"{ui_url}#{urlencode(fragment)}"
4499
+ # El modo ejecución (que rechaza --keep-server) detecta con el latido que el navegador se cerró sin devolver resultado.
4500
+ exit_on_close = not args.keep_server
3823
4501
  EXIT_ON_CLOSE = exit_on_close
3824
4502
 
3825
4503
  print("=" * 64)
@@ -3844,11 +4522,22 @@ def main():
3844
4522
  print(" Auto-cierre : Desactivado")
3845
4523
  print("=" * 64, flush=True)
3846
4524
 
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)
4525
+ headless_browser = None
4526
+ if RUN_SESSION:
4527
+ headless_browser = launch_headless_browser(target_url) if args.headless else None
4528
+ if headless_browser is None and not launch_browser(target_url):
4529
+ run_error("No se pudo abrir el navegador.")
4530
+ server.server_close()
4531
+ sys.stdout = result_out
4532
+ sys.exit(RUN_EXIT_SETUP)
4533
+ if headless_browser is not None:
4534
+ threading.Thread(target=fall_back_when_headless_exits, args=(headless_browser, target_url), daemon=True).start()
4535
+ threading.Thread(target=run_supervisor, args=(server, RUN_SESSION), daemon=True).start()
4536
+ else:
4537
+ CONSOLE_CONTROL = ConsoleControl(server, parser, target_url=target_url)
4538
+ CONSOLE_CONTROL.start()
4539
+ if not args.no_browser:
4540
+ launch_browser(target_url)
3852
4541
 
3853
4542
  if exit_on_close:
3854
4543
  require_initial = not args.no_browser
@@ -3877,6 +4566,17 @@ def main():
3877
4566
  CONSOLE_CONTROL.close()
3878
4567
  CONSOLE_CONTROL = None
3879
4568
 
4569
+ if RUN_SESSION:
4570
+ # Firefox sin ventana sigue vivo al cerrar la página; uno ya abierto solo recibió la pestaña.
4571
+ if headless_browser is not None and headless_browser.poll() is None:
4572
+ _kill_process_tree(headless_browser)
4573
+ code, text = run_outcome(RUN_SESSION)
4574
+ sys.stdout = result_out
4575
+ if text is not None:
4576
+ result_out.write(text if text.endswith("\n") else text + "\n")
4577
+ result_out.flush()
4578
+ sys.exit(code)
4579
+
3880
4580
 
3881
4581
  if __name__ == "__main__":
3882
4582
  main()
File without changes
File without changes