zerochat 7.8.0__tar.gz → 7.11.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zerochat
3
- Version: 7.8.0
3
+ Version: 7.11.0
4
4
  Summary: Private local web client for AI models, tools and MCP services
5
5
  Author: Alberto Alday
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "zerochat"
7
- version = "7.8.0"
7
+ version = "7.11.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: 7.8.0
3
+ Version: 7.11.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
@@ -12,8 +12,10 @@ Proporciona:
12
12
  from __future__ import annotations
13
13
 
14
14
  import argparse
15
+ import ast
15
16
  import atexit
16
17
  import datetime
18
+ import fnmatch
17
19
  import hmac
18
20
  import importlib.metadata
19
21
  import json
@@ -27,6 +29,7 @@ import shlex
27
29
  import signal
28
30
  import subprocess
29
31
  import sys
32
+ import tempfile
30
33
  import threading
31
34
  import time
32
35
  import traceback
@@ -37,7 +40,7 @@ import webbrowser
37
40
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
38
41
  from pathlib import Path
39
42
 
40
- SOURCE_BACKEND_VERSION = "7.8.0"
43
+ SOURCE_BACKEND_VERSION = "7.11.0"
41
44
 
42
45
  def _read_source_version(filename: str) -> str | None:
43
46
  """Lee la versión de un archivo del repositorio cuando se ejecuta desde fuentes."""
@@ -105,9 +108,10 @@ def format_uptime(seconds: float) -> str:
105
108
 
106
109
  class ConsoleControl:
107
110
  """Atajos de consola y línea de estado, solo para terminales interactivos."""
108
- def __init__(self, server: ThreadingHTTPServer, parser: argparse.ArgumentParser):
111
+ def __init__(self, server: ThreadingHTTPServer, parser: argparse.ArgumentParser, target_url: str | None = None):
109
112
  self.server = server
110
113
  self.parser = parser
114
+ self.target_url = target_url
111
115
  self.started_at = time.monotonic()
112
116
  self.last_activity = self.started_at
113
117
  self.stop_event = threading.Event()
@@ -187,7 +191,7 @@ class ConsoleControl:
187
191
  if self.status_visible:
188
192
  sys.stdout.write("\r\033[2K")
189
193
  self.status_visible = False
190
- print("\nComandos de consola: [h] ayuda · [x] salir ordenadamente\n", flush=True)
194
+ print("\nComandos de consola: [h] ayuda · [n] Navegador · [x] salir ordenadamente\n", flush=True)
191
195
  print(self.parser.format_help().rstrip(), flush=True)
192
196
  self.last_activity = time.monotonic()
193
197
 
@@ -198,7 +202,7 @@ class ConsoleControl:
198
202
  with self.lock:
199
203
  if time.monotonic() - self.last_activity < CONSOLE_STATUS_IDLE_SECONDS:
200
204
  return
201
- sys.stdout.write(f"\r\033[2KZeroChat activo {uptime} · [h] ayuda · [x] salir")
205
+ sys.stdout.write(f"\r\033[2KZeroChat activo {uptime} · [h] ayuda · [n] Navegador · [x] salir")
202
206
  sys.stdout.flush()
203
207
  self.status_visible = True
204
208
 
@@ -209,6 +213,9 @@ class ConsoleControl:
209
213
  def _handle_key(self, key: str):
210
214
  if key.lower() == "h":
211
215
  self.show_help()
216
+ elif key.lower() == "n":
217
+ if self.target_url:
218
+ launch_browser(self.target_url)
212
219
  elif key.lower() == "x":
213
220
  self.log(f"[{time.strftime('%H:%M:%S')}] Deteniendo servidor ZeroChat...")
214
221
  stop_zerochat_server(self.server)
@@ -388,34 +395,54 @@ def check_version():
388
395
  # Herramientas Locales Core
389
396
  # ==============================================================================
390
397
 
391
- def list_directory(path: str = ".") -> str:
392
- """Lista los archivos y carpetas de un directorio, sin recorrer subdirectorios."""
398
+ def list_directory(path: str = ".", recursive: bool = False) -> str:
399
+ """List files and directories in a local directory with safe bounded recursive traversal."""
393
400
  try:
394
401
  target = Path(path).expanduser().resolve()
395
402
  if not target.exists():
396
- return json.dumps({"success": False, "error": f"La ruta '{path}' no existe."}, ensure_ascii=False)
403
+ return json.dumps({"success": False, "error": f"Path '{path}' does not exist."}, ensure_ascii=False)
397
404
  if not target.is_dir():
398
- return json.dumps({"success": False, "error": f"La ruta '{path}' no es un directorio."}, ensure_ascii=False)
405
+ return json.dumps({"success": False, "error": f"Path '{path}' is not a directory."}, ensure_ascii=False)
399
406
 
407
+ max_depth = 3 if recursive else 1
400
408
  entries = []
401
- for entry in os.scandir(target):
409
+
410
+ def _scan(current_path: Path, current_depth: int):
411
+ if current_depth > max_depth or len(entries) >= 1000:
412
+ return
402
413
  try:
403
- stat = entry.stat(follow_symlinks=False)
404
- is_dir = entry.is_dir(follow_symlinks=False)
405
- entries.append({
406
- "name": entry.name,
407
- "path": str(Path(entry.path).resolve()),
408
- "type": "directory" if is_dir else "file",
409
- "size_bytes": None if is_dir else stat.st_size,
410
- "is_symlink": entry.is_symlink()
411
- })
414
+ with os.scandir(current_path) as it:
415
+ items = list(it)
416
+ items.sort(key=lambda e: (not e.is_dir(follow_symlinks=False), e.name.lower()))
417
+ for entry in items:
418
+ if len(entries) >= 1000:
419
+ break
420
+ try:
421
+ stat = entry.stat(follow_symlinks=False)
422
+ is_dir = entry.is_dir(follow_symlinks=False)
423
+ entry_data = {
424
+ "name": entry.name,
425
+ "path": str(Path(entry.path).resolve()),
426
+ "type": "directory" if is_dir else "file",
427
+ "size_bytes": None if is_dir else stat.st_size,
428
+ "is_symlink": entry.is_symlink()
429
+ }
430
+ if recursive:
431
+ entry_data["relative_path"] = str(Path(entry.path).resolve().relative_to(target))
432
+ entries.append(entry_data)
433
+ if recursive and is_dir and not entry.is_symlink():
434
+ _scan(Path(entry.path), current_depth + 1)
435
+ except (PermissionError, FileNotFoundError):
436
+ continue
412
437
  except (PermissionError, FileNotFoundError):
413
- continue
438
+ pass
439
+
440
+ _scan(target, 1)
414
441
 
415
- entries.sort(key=lambda e: (e["type"] != "directory", e["name"].lower()))
416
442
  return json.dumps({
417
443
  "success": True,
418
444
  "path": str(target),
445
+ "recursive": bool(recursive),
419
446
  "total_items": len(entries),
420
447
  "entries": entries
421
448
  }, ensure_ascii=False, indent=2)
@@ -423,34 +450,65 @@ def list_directory(path: str = ".") -> str:
423
450
  return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
424
451
 
425
452
 
426
- def read_file(path: str, start_line: int = 1, max_lines: int = 500, max_bytes: int = 100000) -> str:
427
- """Lee el contenido de texto de un archivo local con rangos y límites seguros."""
453
+ def read_file(path: str, start_line: int = 1, end_line: int = None, max_lines: int = 500, max_bytes: int = 100000) -> str:
454
+ """Read text content from a local file in streaming mode with safe ranges and bounds."""
428
455
  try:
429
456
  target = Path(path).expanduser().resolve()
430
457
  if not target.exists():
431
- return json.dumps({"success": False, "error": f"El archivo '{path}' no existe."}, ensure_ascii=False)
458
+ return json.dumps({"success": False, "error": f"File '{path}' does not exist."}, ensure_ascii=False)
432
459
  if not target.is_file():
433
- return json.dumps({"success": False, "error": f"La ruta '{path}' no es un archivo regular."}, ensure_ascii=False)
460
+ return json.dumps({"success": False, "error": f"Path '{path}' is not a regular file."}, ensure_ascii=False)
434
461
 
435
462
  file_size = target.stat().st_size
436
463
  safe_max_bytes = max(1024, min(int(max_bytes), 2000000))
437
464
  safe_start_line = max(1, int(start_line))
438
- safe_max_lines = max(1, min(int(max_lines), 2000))
465
+
466
+ if end_line is not None:
467
+ safe_end_line = max(safe_start_line, int(end_line))
468
+ target_line_count = safe_end_line - safe_start_line + 1
469
+ else:
470
+ safe_end_line = None
471
+ target_line_count = max(1, min(int(max_lines), 2000))
472
+
473
+ selected_lines = []
474
+ current_bytes = 0
475
+ truncated_bytes = False
476
+ total_lines = 0
477
+ reached_end_bound = False
439
478
 
440
479
  with open(target, "r", encoding="utf-8", errors="replace") as f:
441
- lines = f.readlines()
480
+ for line_no, line in enumerate(f, 1):
481
+ total_lines = line_no
482
+ if line_no < safe_start_line:
483
+ continue
484
+ if safe_end_line is not None and line_no > safe_end_line:
485
+ reached_end_bound = True
486
+ if line_no > safe_start_line + 50000:
487
+ break
488
+ continue
489
+ if safe_end_line is None and len(selected_lines) >= target_line_count:
490
+ reached_end_bound = True
491
+ if line_no > safe_start_line + 50000:
492
+ break
493
+ continue
442
494
 
443
- total_lines = len(lines)
444
- start_idx = safe_start_line - 1
445
- end_idx = min(start_idx + safe_max_lines, total_lines)
495
+ if not reached_end_bound:
496
+ line_bytes = len(line.encode("utf-8"))
497
+ if current_bytes + line_bytes > safe_max_bytes:
498
+ remaining_budget = max(0, safe_max_bytes - current_bytes)
499
+ if remaining_budget > 0:
500
+ encoded = line.encode("utf-8")[:remaining_budget]
501
+ selected_lines.append(encoded.decode("utf-8", errors="ignore"))
502
+ truncated_bytes = True
503
+ reached_end_bound = True
504
+ else:
505
+ selected_lines.append(line)
506
+ current_bytes += line_bytes
446
507
 
447
- selected_lines = lines[start_idx:end_idx] if start_idx < total_lines else []
448
508
  content = "".join(selected_lines)
509
+ last_returned_line = safe_start_line + len(selected_lines) - 1 if selected_lines else safe_start_line - 1
449
510
 
450
- truncated_bytes = False
451
- if len(content.encode("utf-8")) > safe_max_bytes:
452
- content = content[:safe_max_bytes]
453
- truncated_bytes = True
511
+ is_truncated = truncated_bytes or (safe_end_line is not None and safe_end_line < total_lines) or (safe_end_line is None and (safe_start_line + target_line_count - 1) < total_lines)
454
512
 
455
513
  return json.dumps({
456
514
  "success": True,
@@ -458,44 +516,103 @@ def read_file(path: str, start_line: int = 1, max_lines: int = 500, max_bytes: i
458
516
  "size_bytes": file_size,
459
517
  "total_lines": total_lines,
460
518
  "start_line": safe_start_line,
519
+ "end_line": last_returned_line,
461
520
  "lines_returned": len(selected_lines),
462
- "truncated": truncated_bytes or end_idx < total_lines,
521
+ "truncated": is_truncated,
463
522
  "content": content
464
523
  }, ensure_ascii=False, indent=2)
465
524
  except Exception as e:
466
525
  return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
467
526
 
468
527
 
469
- def edit_file(path: str, content: str, mode: str = "write", target_content: str = None) -> str:
470
- """Crea, sobrescribe o edita un archivo de forma atómica."""
528
+ def write_file(path: str, content: str) -> str:
529
+ """Create or overwrite a file completely and atomically."""
471
530
  try:
472
531
  target = Path(path).expanduser().resolve()
473
532
  target.parent.mkdir(parents=True, exist_ok=True)
533
+ temp_path = target.with_suffix(target.suffix + f".tmp_{os.getpid()}_{time.time_ns()}")
534
+ with open(temp_path, "w", encoding="utf-8") as f:
535
+ f.write(content)
536
+ temp_path.replace(target)
537
+ bytes_written = len(content.encode("utf-8"))
538
+ return json.dumps({
539
+ "success": True,
540
+ "path": str(target),
541
+ "bytes_written": bytes_written
542
+ }, ensure_ascii=False, indent=2)
543
+ except Exception as e:
544
+ return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
474
545
 
546
+
547
+ def edit_file(path: str, old_str: str = None, new_str: str = None, content: str = None, mode: str = "surgical", target_content: str = None) -> str:
548
+ """Modify an existing file surgically or atomically."""
549
+ try:
550
+ target = Path(path).expanduser().resolve()
551
+
552
+ # Preferred surgical mode (old_str -> new_str)
553
+ if old_str is not None and new_str is not None:
554
+ if not target.exists():
555
+ return json.dumps({"success": False, "error": f"File '{path}' does not exist. Use read_file to verify existing paths."}, ensure_ascii=False)
556
+ if not target.is_file():
557
+ return json.dumps({"success": False, "error": f"Path '{path}' is not a regular file."}, ensure_ascii=False)
558
+
559
+ with open(target, "r", encoding="utf-8", errors="replace") as f:
560
+ file_text = f.read()
561
+
562
+ occurrences = file_text.count(old_str)
563
+ if occurrences == 0:
564
+ return json.dumps({
565
+ "success": False,
566
+ "error": f"Target text was not found in '{path}'. Check exact file content with read_file."
567
+ }, ensure_ascii=False)
568
+ if occurrences > 1:
569
+ return json.dumps({
570
+ "success": False,
571
+ "error": f"Found {occurrences} matches for the snippet in '{path}'. Provide more surrounding context in old_str to ensure a unique match."
572
+ }, ensure_ascii=False)
573
+
574
+ updated_text = file_text.replace(old_str, new_str, 1)
575
+ temp_path = target.with_suffix(target.suffix + f".tmp_{os.getpid()}_{time.time_ns()}")
576
+ with open(temp_path, "w", encoding="utf-8") as f:
577
+ f.write(updated_text)
578
+ temp_path.replace(target)
579
+ return json.dumps({
580
+ "success": True,
581
+ "path": str(target),
582
+ "mode": "surgical",
583
+ "bytes_written": len(updated_text.encode("utf-8")),
584
+ "replacements": 1
585
+ }, ensure_ascii=False, indent=2)
586
+
587
+ # Legacy modes (write, append, replace_chunk)
588
+ if content is None:
589
+ return json.dumps({"success": False, "error": "Must provide old_str and new_str for surgical edit, or content for compatible modes."}, ensure_ascii=False)
590
+
591
+ target.parent.mkdir(parents=True, exist_ok=True)
475
592
  if mode == "append":
476
593
  with open(target, "a", encoding="utf-8") as f:
477
594
  f.write(content)
478
595
  bytes_written = len(content.encode("utf-8"))
479
596
  elif mode == "replace_chunk":
480
597
  if not target.exists():
481
- return json.dumps({"success": False, "error": f"El archivo '{path}' no existe para replace_chunk."}, ensure_ascii=False)
598
+ return json.dumps({"success": False, "error": f"File '{path}' does not exist for replace_chunk."}, ensure_ascii=False)
482
599
  if not target_content:
483
- return json.dumps({"success": False, "error": "target_content es obligatorio en modo replace_chunk."}, ensure_ascii=False)
600
+ return json.dumps({"success": False, "error": "target_content is required in replace_chunk mode."}, ensure_ascii=False)
484
601
 
485
602
  with open(target, "r", encoding="utf-8", errors="replace") as f:
486
603
  existing = f.read()
487
604
 
488
605
  if target_content not in existing:
489
- return json.dumps({"success": False, "error": "target_content no fue encontrado en el archivo."}, ensure_ascii=False)
606
+ return json.dumps({"success": False, "error": "target_content was not found in the file."}, ensure_ascii=False)
490
607
 
491
608
  new_text = existing.replace(target_content, content, 1)
492
- temp_path = target.with_suffix(target.suffix + f".tmp_{os.getpid()}")
609
+ temp_path = target.with_suffix(target.suffix + f".tmp_{os.getpid()}_{time.time_ns()}")
493
610
  with open(temp_path, "w", encoding="utf-8") as f:
494
611
  f.write(new_text)
495
612
  temp_path.replace(target)
496
613
  bytes_written = len(new_text.encode("utf-8"))
497
614
  else: # write
498
- temp_path = target.with_suffix(target.suffix + f".tmp_{os.getpid()}")
615
+ temp_path = target.with_suffix(target.suffix + f".tmp_{os.getpid()}_{time.time_ns()}")
499
616
  with open(temp_path, "w", encoding="utf-8") as f:
500
617
  f.write(content)
501
618
  temp_path.replace(target)
@@ -511,8 +628,135 @@ def edit_file(path: str, content: str, mode: str = "write", target_content: str
511
628
  return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
512
629
 
513
630
 
631
+ def truncate_terminal_output(text: str, max_chars: int = 8000, head_lines: int = 50, tail_lines: int = 30) -> tuple[str, bool]:
632
+ """Outputs >8,000 characters are truncated to first 50 lines + warning + last 30 lines."""
633
+ if len(text) <= max_chars:
634
+ return text, False
635
+ lines = text.splitlines(keepends=True)
636
+ if len(lines) <= head_lines + tail_lines:
637
+ half = max_chars // 2
638
+ return (
639
+ text[:half]
640
+ + f"\n\n[... Output truncated due to length ({len(text)} total characters) ...]\n\n"
641
+ + text[-half:],
642
+ True
643
+ )
644
+ omitted = len(lines) - head_lines - tail_lines
645
+ head = "".join(lines[:head_lines])
646
+ tail = "".join(lines[-tail_lines:])
647
+ warning = f"\n\n[... Output truncated: omitted {omitted} lines ({len(text)} total characters) ...]\n\n"
648
+ return head + warning + tail, True
649
+
650
+
651
+ class PersistentBashSession:
652
+ """Maintains working directory (cwd) and environment variables across successive calls."""
653
+ def __init__(self):
654
+ self._dir = tempfile.mkdtemp(prefix="zerochat_bash_")
655
+ self._cwd_file = Path(self._dir) / "cwd"
656
+ self._env_file = Path(self._dir) / "env.sh"
657
+ self._cwd = str(Path.cwd().resolve())
658
+ self._lock = threading.Lock()
659
+ atexit.register(self.cleanup)
660
+
661
+ def cleanup(self):
662
+ try:
663
+ shutil.rmtree(self._dir, ignore_errors=True)
664
+ except Exception:
665
+ pass
666
+
667
+ def run(self, command: str, timeout_seconds: int = 30) -> str:
668
+ with self._lock:
669
+ safe_timeout = max(1, min(int(timeout_seconds), 300))
670
+ runner_script = Path(self._dir) / f"runner_{time.time_ns()}.sh"
671
+
672
+ script_content = (
673
+ "if [ -f " + shlex.quote(str(self._env_file)) + " ]; then\n"
674
+ " . " + shlex.quote(str(self._env_file)) + " 2>/dev/null || true\n"
675
+ "fi\n"
676
+ "cd " + shlex.quote(self._cwd) + " 2>/dev/null || true\n"
677
+ "trap '__ret=$?; pwd > " + shlex.quote(str(self._cwd_file)) + "; export -p > " + shlex.quote(str(self._env_file)) + " 2>/dev/null || true; exit $__ret' EXIT\n"
678
+ + command + "\n"
679
+ )
680
+
681
+ try:
682
+ with open(runner_script, "w", encoding="utf-8") as f:
683
+ f.write(script_content)
684
+ runner_script.chmod(0o700)
685
+
686
+ proc = subprocess.Popen(
687
+ ["bash", str(runner_script)],
688
+ cwd=self._cwd,
689
+ stdout=subprocess.PIPE,
690
+ stderr=subprocess.PIPE,
691
+ text=True,
692
+ encoding="utf-8",
693
+ errors="replace",
694
+ start_new_session=True if hasattr(os, "setsid") else False
695
+ )
696
+
697
+ try:
698
+ stdout, stderr = proc.communicate(timeout=safe_timeout)
699
+ returncode = proc.returncode
700
+ except subprocess.TimeoutExpired:
701
+ try:
702
+ if hasattr(os, "killpg") and hasattr(os, "getpgid"):
703
+ os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
704
+ else:
705
+ proc.kill()
706
+ except Exception:
707
+ try:
708
+ proc.kill()
709
+ except Exception:
710
+ pass
711
+ proc.communicate()
712
+ return json.dumps({
713
+ "success": False,
714
+ "error": f"Command timed out after {timeout_seconds} seconds (terminated with SIGKILL).",
715
+ "cwd": self._cwd
716
+ }, ensure_ascii=False)
717
+
718
+ if self._cwd_file.is_file():
719
+ try:
720
+ saved_cwd = self._cwd_file.read_text(encoding="utf-8").strip()
721
+ if saved_cwd and Path(saved_cwd).is_dir():
722
+ self._cwd = saved_cwd
723
+ except Exception:
724
+ pass
725
+
726
+ truncated_out, was_out_trunc = truncate_terminal_output(stdout)
727
+ truncated_err, was_err_trunc = truncate_terminal_output(stderr)
728
+
729
+ return json.dumps({
730
+ "success": returncode == 0,
731
+ "returncode": returncode,
732
+ "stdout": truncated_out,
733
+ "stderr": truncated_err,
734
+ "cwd": self._cwd,
735
+ "truncated": was_out_trunc or was_err_trunc
736
+ }, ensure_ascii=False, indent=2)
737
+
738
+ except Exception as e:
739
+ return json.dumps({"success": False, "error": str(e), "cwd": self._cwd}, ensure_ascii=False)
740
+ finally:
741
+ try:
742
+ if runner_script.is_file():
743
+ runner_script.unlink()
744
+ except Exception:
745
+ pass
746
+
747
+
748
+ BASH_SESSION = PersistentBashSession()
749
+
750
+
751
+ def bash(command: str, timeout_seconds: int = 30) -> str:
752
+ """Execute a command in an interactive persistent bash shell session."""
753
+ return BASH_SESSION.run(command, timeout_seconds=timeout_seconds)
754
+
755
+
514
756
  def execute_command(command: str, cwd: str = ".", timeout_seconds: int = 60) -> str:
515
- """Ejecuta un comando en la shell del sistema y devuelve stdout y stderr."""
757
+ """Execute a command in the system shell and capture stdout and stderr."""
758
+ if cwd == "." or cwd == BASH_SESSION._cwd:
759
+ return BASH_SESSION.run(command, timeout_seconds=timeout_seconds)
516
760
  try:
517
761
  target_cwd = Path(cwd).expanduser().resolve()
518
762
  if not target_cwd.exists() or not target_cwd.is_dir():
@@ -528,15 +772,417 @@ def execute_command(command: str, cwd: str = ".", timeout_seconds: int = 60) ->
528
772
  encoding="utf-8",
529
773
  errors="replace"
530
774
  )
775
+ trunc_out, was_out_trunc = truncate_terminal_output(proc.stdout)
776
+ trunc_err, was_err_trunc = truncate_terminal_output(proc.stderr)
531
777
  return json.dumps({
532
778
  "success": proc.returncode == 0,
533
779
  "returncode": proc.returncode,
534
- "stdout": proc.stdout,
535
- "stderr": proc.stderr,
536
- "cwd": str(target_cwd)
780
+ "stdout": trunc_out,
781
+ "stderr": trunc_err,
782
+ "cwd": str(target_cwd),
783
+ "truncated": was_out_trunc or was_err_trunc
537
784
  }, ensure_ascii=False, indent=2)
538
785
  except subprocess.TimeoutExpired:
539
- return json.dumps({"success": False, "error": f"Comando excedió el tiempo límite de {timeout_seconds} segundos."}, ensure_ascii=False)
786
+ return json.dumps({"success": False, "error": f"Command timed out after {timeout_seconds} seconds."}, ensure_ascii=False)
787
+ except Exception as e:
788
+ return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
789
+
790
+
791
+ def search_files(query: str, path: str = ".", file_pattern: str = None, max_results: int = 100) -> str:
792
+ """Recursively search for plain text or regular expressions across project files."""
793
+ try:
794
+ target = Path(path).expanduser().resolve()
795
+ if not target.exists():
796
+ return json.dumps({"success": False, "error": f"Path '{path}' does not exist."}, ensure_ascii=False)
797
+ if not target.is_dir():
798
+ return json.dumps({"success": False, "error": f"Path '{path}' is not a directory."}, ensure_ascii=False)
799
+
800
+ try:
801
+ regex = re.compile(query, re.MULTILINE)
802
+ except re.error:
803
+ regex = re.compile(re.escape(query), re.MULTILINE)
804
+
805
+ ignored_dirs = {".git", ".venv", "node_modules", "__pycache__", ".pytest_cache", ".cache"}
806
+ matches = []
807
+ files_searched = 0
808
+ max_file_size = 2 * 1024 * 1024
809
+ truncated = False
810
+
811
+ for root, dirs, files in os.walk(target):
812
+ dirs[:] = [d for d in dirs if d not in ignored_dirs and not d.startswith(".")]
813
+
814
+ for fname in sorted(files):
815
+ if file_pattern and not fnmatch.fnmatch(fname, file_pattern):
816
+ continue
817
+
818
+ fpath = Path(root) / fname
819
+ try:
820
+ stat = fpath.stat()
821
+ if stat.st_size > max_file_size:
822
+ continue
823
+ except OSError:
824
+ continue
825
+
826
+ files_searched += 1
827
+ try:
828
+ with open(fpath, "r", encoding="utf-8", errors="ignore") as f:
829
+ for line_idx, line in enumerate(f, 1):
830
+ if regex.search(line):
831
+ try:
832
+ rel_path = str(fpath.relative_to(target))
833
+ except ValueError:
834
+ rel_path = str(fpath)
835
+ matches.append({
836
+ "file": str(fpath),
837
+ "relative_path": rel_path,
838
+ "line_number": line_idx,
839
+ "content": line.rstrip("\r\n")[:300]
840
+ })
841
+ if len(matches) >= max_results:
842
+ truncated = True
843
+ break
844
+ except (PermissionError, OSError):
845
+ continue
846
+
847
+ if truncated:
848
+ break
849
+ if truncated:
850
+ break
851
+
852
+ return json.dumps({
853
+ "success": True,
854
+ "path": str(target),
855
+ "query": query,
856
+ "file_pattern": file_pattern,
857
+ "files_searched": files_searched,
858
+ "total_matches": len(matches),
859
+ "truncated": truncated,
860
+ "matches": matches
861
+ }, ensure_ascii=False, indent=2)
862
+ except Exception as e:
863
+ return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
864
+
865
+
866
+ def get_diagnostics(path: str | None = None) -> str:
867
+ """Retrieve syntax diagnostics and code errors for a file or the entire workspace."""
868
+ try:
869
+ if path is not None and str(path).strip():
870
+ target = Path(str(path).strip()).expanduser().resolve()
871
+ else:
872
+ target = Path.cwd().resolve()
873
+
874
+ if not target.exists():
875
+ return json.dumps({
876
+ "success": False,
877
+ "error": f"Path '{path}' does not exist."
878
+ }, ensure_ascii=False)
879
+
880
+ diagnostics: list[dict] = []
881
+ files_checked = 0
882
+
883
+ def check_single_file(fpath: Path) -> list[dict]:
884
+ ext = fpath.suffix.lower()
885
+ file_diags: list[dict] = []
886
+ try:
887
+ rel_display = str(fpath.relative_to(Path.cwd()))
888
+ except ValueError:
889
+ rel_display = str(fpath)
890
+
891
+ if ext == ".py":
892
+ try:
893
+ content = fpath.read_text(encoding="utf-8", errors="replace")
894
+ compile(content, str(fpath), "exec")
895
+ except (SyntaxError, IndentationError) as e:
896
+ file_diags.append({
897
+ "file": rel_display,
898
+ "line": e.lineno or 1,
899
+ "column": e.offset or 1,
900
+ "severity": "error",
901
+ "message": f"SyntaxError: {e.msg}"
902
+ })
903
+ elif ext in (".js", ".mjs", ".cjs"):
904
+ node_bin = shutil.which("node")
905
+ if node_bin:
906
+ try:
907
+ res = subprocess.run([node_bin, "--check", str(fpath)], capture_output=True, text=True, timeout=10)
908
+ if res.returncode != 0:
909
+ stderr = res.stderr or ""
910
+ line_match = re.search(r':(\d+)\n([^\n]+)\n(\s*)\^', stderr)
911
+ line = int(line_match.group(1)) if line_match else 1
912
+ col = len(line_match.group(3)) + 1 if line_match else 1
913
+ msg_match = re.search(r'(SyntaxError:[^\n]+)', stderr)
914
+ msg = msg_match.group(1) if msg_match else (stderr.strip().splitlines()[-1] if stderr.strip() else "Syntax error")
915
+ file_diags.append({
916
+ "file": rel_display,
917
+ "line": line,
918
+ "column": col,
919
+ "severity": "error",
920
+ "message": msg
921
+ })
922
+ except Exception:
923
+ pass
924
+ elif ext == ".json":
925
+ try:
926
+ content = fpath.read_text(encoding="utf-8", errors="replace")
927
+ json.loads(content)
928
+ except json.JSONDecodeError as e:
929
+ file_diags.append({
930
+ "file": rel_display,
931
+ "line": e.lineno,
932
+ "column": e.colno,
933
+ "severity": "error",
934
+ "message": f"JSONDecodeError: {e.msg}"
935
+ })
936
+ return file_diags
937
+
938
+ if target.is_file():
939
+ files_checked = 1
940
+ diagnostics.extend(check_single_file(target))
941
+ else:
942
+ ignore_dirs = {".git", "node_modules", ".venv", "venv", "dist", "build", "__pycache__", ".gemini", ".cache"}
943
+ max_scan = 200
944
+ for root, dirs, files in os.walk(target):
945
+ dirs[:] = [d for d in dirs if d not in ignore_dirs and not d.startswith(".")]
946
+ for fname in sorted(files):
947
+ fp = Path(root) / fname
948
+ if fp.suffix.lower() in (".py", ".js", ".mjs", ".cjs", ".json"):
949
+ files_checked += 1
950
+ diagnostics.extend(check_single_file(fp))
951
+ if files_checked >= max_scan:
952
+ break
953
+ if files_checked >= max_scan:
954
+ break
955
+
956
+ error_count = sum(1 for d in diagnostics if d.get("severity") == "error")
957
+ warning_count = sum(1 for d in diagnostics if d.get("severity") == "warning")
958
+ msg = f"Found {len(diagnostics)} issue(s)." if diagnostics else "No diagnostic issues found."
959
+
960
+ return json.dumps({
961
+ "success": True,
962
+ "path": str(target),
963
+ "files_checked": files_checked,
964
+ "error_count": error_count,
965
+ "warning_count": warning_count,
966
+ "diagnostics": diagnostics,
967
+ "message": msg
968
+ }, ensure_ascii=False, indent=2)
969
+ except Exception as e:
970
+ return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
971
+
972
+
973
+ class PersistentBrowserSession:
974
+ """Manages a persistent headless browser session (Playwright) for browser_action."""
975
+
976
+ def __init__(self):
977
+ self._lock = threading.Lock()
978
+ self._process: subprocess.Popen | None = None
979
+ atexit.register(self.close)
980
+
981
+ def _ensure_running(self) -> subprocess.Popen:
982
+ if self._process is not None and self._process.poll() is None:
983
+ return self._process
984
+
985
+ node = shutil.which("node")
986
+ if not node:
987
+ raise RuntimeError("Node.js is not installed or not found in system PATH.")
988
+
989
+ runner_js = """
990
+ const readline = require('readline');
991
+ let playwright;
992
+ try {
993
+ playwright = require('playwright');
994
+ } catch (e1) {
995
+ try {
996
+ const path = require('path');
997
+ const home = process.env.HOME || process.env.USERPROFILE || '';
998
+ playwright = require(path.join(home, 'zerochat', 'services', 'playwright', 'node_modules', 'playwright'));
999
+ } catch (e2) {
1000
+ console.log(JSON.stringify({
1001
+ success: false,
1002
+ error: "Playwright is not available. Install it with 'npm install playwright' or enable the Playwright MCP service."
1003
+ }));
1004
+ process.exit(1);
1005
+ }
1006
+ }
1007
+
1008
+ (async () => {
1009
+ let browser, context, page;
1010
+ try {
1011
+ const candidates = [
1012
+ {}, // 1. Playwright bundled Chromium
1013
+ { channel: 'chrome' }, // 2. System Google Chrome
1014
+ { channel: 'msedge' }, // 3. System Microsoft Edge (Windows 10/11)
1015
+ { channel: 'chromium' } // 4. System Chromium (/usr/bin/chromium)
1016
+ ];
1017
+ let lastErr = null;
1018
+ for (const cand of candidates) {
1019
+ try {
1020
+ browser = await playwright.chromium.launch({ ...cand, headless: true });
1021
+ if (browser) break;
1022
+ } catch (e) {
1023
+ lastErr = e;
1024
+ }
1025
+ }
1026
+ if (!browser) {
1027
+ throw lastErr || new Error('No Chromium-based browser found (Playwright, Chrome, Edge, or Chromium).');
1028
+ }
1029
+ context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
1030
+ page = await context.newPage();
1031
+ } catch (err) {
1032
+ console.log(JSON.stringify({ success: false, error: 'Failed to start browser: ' + err.message }));
1033
+ process.exit(1);
1034
+ }
1035
+
1036
+ console.log(JSON.stringify({ ready: true }));
1037
+
1038
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: false });
1039
+ for await (const line of rl) {
1040
+ if (!line.trim()) continue;
1041
+ let req;
1042
+ try {
1043
+ req = JSON.parse(line);
1044
+ } catch (e) {
1045
+ console.log(JSON.stringify({ success: false, error: 'Invalid command JSON' }));
1046
+ continue;
1047
+ }
1048
+
1049
+ try {
1050
+ const act = req.action;
1051
+ if (act === 'navigate') {
1052
+ if (!req.url) throw new Error("Parameter 'url' is required for action 'navigate'");
1053
+ await page.goto(req.url, { waitUntil: 'domcontentloaded', timeout: 30000 });
1054
+ console.log(JSON.stringify({
1055
+ success: true,
1056
+ action: 'navigate',
1057
+ url: page.url(),
1058
+ title: await page.title()
1059
+ }));
1060
+ } else if (act === 'screenshot') {
1061
+ if (req.url && req.url !== page.url()) {
1062
+ await page.goto(req.url, { waitUntil: 'domcontentloaded', timeout: 30000 });
1063
+ }
1064
+ const buf = await page.screenshot({ fullPage: false });
1065
+ console.log(JSON.stringify({
1066
+ success: true,
1067
+ action: 'screenshot',
1068
+ image_base64: buf.toString('base64'),
1069
+ mime_type: 'image/png',
1070
+ url: page.url(),
1071
+ title: await page.title()
1072
+ }));
1073
+ } else if (act === 'click') {
1074
+ if (!req.selector) throw new Error("Parameter 'selector' is required for action 'click'");
1075
+ await page.click(req.selector, { timeout: 15000 });
1076
+ console.log(JSON.stringify({
1077
+ success: true,
1078
+ action: 'click',
1079
+ selector: req.selector,
1080
+ url: page.url()
1081
+ }));
1082
+ } else if (act === 'fill') {
1083
+ if (!req.selector) throw new Error("Parameter 'selector' is required for action 'fill'");
1084
+ await page.fill(req.selector, req.value || '', { timeout: 15000 });
1085
+ console.log(JSON.stringify({
1086
+ success: true,
1087
+ action: 'fill',
1088
+ selector: req.selector,
1089
+ value: req.value || '',
1090
+ url: page.url()
1091
+ }));
1092
+ } else if (act === 'close') {
1093
+ await browser.close();
1094
+ console.log(JSON.stringify({ success: true, action: 'close' }));
1095
+ process.exit(0);
1096
+ } else {
1097
+ console.log(JSON.stringify({ success: false, error: 'Unsupported action: ' + act }));
1098
+ }
1099
+ } catch (err) {
1100
+ console.log(JSON.stringify({ success: false, error: err.message, action: req.action }));
1101
+ }
1102
+ }
1103
+ })();
1104
+ """
1105
+ proc = subprocess.Popen(
1106
+ [node, "-e", runner_js],
1107
+ stdin=subprocess.PIPE,
1108
+ stdout=subprocess.PIPE,
1109
+ stderr=subprocess.PIPE,
1110
+ text=True,
1111
+ encoding="utf-8",
1112
+ bufsize=1
1113
+ )
1114
+ self._process = proc
1115
+ first_line = proc.stdout.readline()
1116
+ if not first_line:
1117
+ err = proc.stderr.read()
1118
+ self._process = None
1119
+ raise RuntimeError(f"Failed to initialize headless browser: {err or 'process terminated unexpectedly'}")
1120
+ data = json.loads(first_line)
1121
+ if not data.get("ready"):
1122
+ self._process = None
1123
+ raise RuntimeError(data.get("error", "Unknown error starting browser"))
1124
+
1125
+ return proc
1126
+
1127
+ def execute(self, command: dict) -> dict:
1128
+ with self._lock:
1129
+ try:
1130
+ proc = self._ensure_running()
1131
+ except Exception as e:
1132
+ return {"success": False, "error": str(e)}
1133
+
1134
+ req_json = json.dumps(command, ensure_ascii=False) + "\n"
1135
+ try:
1136
+ proc.stdin.write(req_json)
1137
+ proc.stdin.flush()
1138
+ resp_line = proc.stdout.readline()
1139
+ if not resp_line:
1140
+ self.close()
1141
+ return {"success": False, "error": "Browser process closed unexpectedly."}
1142
+ return json.loads(resp_line)
1143
+ except Exception as e:
1144
+ self.close()
1145
+ return {"success": False, "error": f"Error executing browser action: {e}"}
1146
+
1147
+ def close(self):
1148
+ with self._lock:
1149
+ if self._process is not None:
1150
+ try:
1151
+ if self._process.poll() is None:
1152
+ try:
1153
+ self._process.stdin.write(json.dumps({"action": "close"}) + "\n")
1154
+ self._process.stdin.flush()
1155
+ self._process.wait(timeout=2)
1156
+ except Exception:
1157
+ self._process.kill()
1158
+ except Exception:
1159
+ pass
1160
+ self._process = None
1161
+
1162
+
1163
+ _BROWSER_SESSION = PersistentBrowserSession()
1164
+
1165
+
1166
+ def browser_action(action: str, url: str | None = None, selector: str | None = None, value: str | None = None) -> str:
1167
+ """Control a headless browser for UI testing and visual inspection."""
1168
+ try:
1169
+ act = (action or "").strip().lower()
1170
+ if act not in ("navigate", "screenshot", "click", "fill"):
1171
+ return json.dumps({
1172
+ "success": False,
1173
+ "error": f"Invalid browser action: '{action}'. Valid actions: navigate, screenshot, click, fill."
1174
+ }, ensure_ascii=False)
1175
+
1176
+ cmd = {"action": act}
1177
+ if url:
1178
+ cmd["url"] = str(url).strip()
1179
+ if selector:
1180
+ cmd["selector"] = str(selector).strip()
1181
+ if value is not None:
1182
+ cmd["value"] = str(value)
1183
+
1184
+ result = _BROWSER_SESSION.execute(cmd)
1185
+ return json.dumps(result, ensure_ascii=False, indent=2)
540
1186
  except Exception as e:
541
1187
  return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
542
1188
 
@@ -544,62 +1190,136 @@ def execute_command(command: str, cwd: str = ".", timeout_seconds: int = 60) ->
544
1190
  LOCAL_TOOLS_DEFINITIONS = [
545
1191
  {
546
1192
  "name": "list_directory",
547
- "description": "Lista archivos y carpetas de un directorio local, sin recorrer subdirectorios.",
1193
+ "description": "List files and directories in a local directory with safe bounded recursive traversal.",
548
1194
  "inputSchema": {
549
1195
  "type": "object",
550
1196
  "properties": {
551
- "path": {"type": "string", "description": "Ruta relativa o absoluta (por defecto '.')"}
1197
+ "path": {"type": "string", "description": "Relative or absolute directory path (defaults to '.')"},
1198
+ "recursive": {"type": "boolean", "description": "If true, traverses subdirectories up to depth 3", "default": False}
552
1199
  }
553
1200
  }
554
1201
  },
555
1202
  {
556
1203
  "name": "read_file",
557
- "description": "Lee el contenido de texto de un archivo local.",
1204
+ "description": "Read text content from a local file with safe ranges and bounds.",
558
1205
  "inputSchema": {
559
1206
  "type": "object",
560
1207
  "properties": {
561
- "path": {"type": "string", "description": "Ruta del archivo"},
562
- "start_line": {"type": "integer", "description": "Línea inicial (1-indexed)", "default": 1},
563
- "max_lines": {"type": "integer", "description": "Número máximo de líneas a leer", "default": 500},
564
- "max_bytes": {"type": "integer", "description": "Límite máximo de bytes", "default": 100000}
1208
+ "path": {"type": "string", "description": "Path to the file to read"},
1209
+ "start_line": {"type": "integer", "description": "Starting line number (1-indexed)", "default": 1},
1210
+ "end_line": {"type": "integer", "description": "Ending line number (inclusive)"},
1211
+ "max_lines": {"type": "integer", "description": "Maximum number of lines to read when end_line is omitted", "default": 500},
1212
+ "max_bytes": {"type": "integer", "description": "Maximum byte budget", "default": 100000}
565
1213
  },
566
1214
  "required": ["path"]
567
1215
  }
568
1216
  },
569
1217
  {
570
- "name": "edit_file",
571
- "description": "Crea, sobrescribe o modifica un archivo local de forma atómica.",
1218
+ "name": "write_file",
1219
+ "description": "Create or overwrite a file completely and atomically.",
572
1220
  "inputSchema": {
573
1221
  "type": "object",
574
1222
  "properties": {
575
- "path": {"type": "string", "description": "Ruta del archivo a editar"},
576
- "content": {"type": "string", "description": "Contenido a escribir o reemplazar"},
577
- "mode": {"type": "string", "enum": ["write", "append", "replace_chunk"], "default": "write"},
578
- "target_content": {"type": "string", "description": "Texto exacto a reemplazar cuando mode='replace_chunk'"}
1223
+ "path": {"type": "string", "description": "Path to the file to write"},
1224
+ "content": {"type": "string", "description": "Full content of the file"}
579
1225
  },
580
1226
  "required": ["path", "content"]
581
1227
  }
582
1228
  },
1229
+ {
1230
+ "name": "edit_file",
1231
+ "description": "Edit an existing file surgically by replacing old_str with new_str (must match exactly once).",
1232
+ "inputSchema": {
1233
+ "type": "object",
1234
+ "properties": {
1235
+ "path": {"type": "string", "description": "Path to the file to edit"},
1236
+ "old_str": {"type": "string", "description": "Exact snippet to replace (must appear exactly once in the file)"},
1237
+ "new_str": {"type": "string", "description": "Replacement snippet"},
1238
+ "content": {"type": "string", "description": "Content to write in legacy/compatible mode"},
1239
+ "mode": {"type": "string", "enum": ["surgical", "write", "append", "replace_chunk"], "default": "surgical"},
1240
+ "target_content": {"type": "string", "description": "Exact target content in replace_chunk mode"}
1241
+ },
1242
+ "required": ["path"]
1243
+ }
1244
+ },
1245
+ {
1246
+ "name": "bash",
1247
+ "description": "Execute a command in an interactive persistent shell session (preserves cwd and exported environment variables across calls).",
1248
+ "inputSchema": {
1249
+ "type": "object",
1250
+ "properties": {
1251
+ "command": {"type": "string", "description": "Shell command to execute (e.g. npm test, git status)"},
1252
+ "timeout_seconds": {"type": "integer", "description": "Timeout in seconds (defaults to 30)", "default": 30}
1253
+ },
1254
+ "required": ["command"]
1255
+ }
1256
+ },
1257
+ {
1258
+ "name": "search_files",
1259
+ "description": "Recursively search for plain text or regular expressions across project files.",
1260
+ "inputSchema": {
1261
+ "type": "object",
1262
+ "properties": {
1263
+ "query": {"type": "string", "description": "Plain text string or regular expression to search for"},
1264
+ "path": {"type": "string", "description": "Base search directory (defaults to '.')", "default": "."},
1265
+ "file_pattern": {"type": "string", "description": "Optional glob filter (e.g. *.js, *.py)"}
1266
+ },
1267
+ "required": ["query"]
1268
+ }
1269
+ },
583
1270
  {
584
1271
  "name": "execute_command",
585
- "description": "Ejecuta un comando en la shell del sistema y captura la salida.",
1272
+ "description": "Execute a shell command and capture stdout/stderr.",
586
1273
  "inputSchema": {
587
1274
  "type": "object",
588
1275
  "properties": {
589
- "command": {"type": "string", "description": "Comando a ejecutar"},
590
- "cwd": {"type": "string", "description": "Directorio de trabajo (por defecto '.')"},
591
- "timeout_seconds": {"type": "integer", "description": "Tiempo límite en segundos", "default": 60}
1276
+ "command": {"type": "string", "description": "Command to execute"},
1277
+ "cwd": {"type": "string", "description": "Working directory (defaults to '.')"},
1278
+ "timeout_seconds": {"type": "integer", "description": "Timeout in seconds (defaults to 60)", "default": 60}
592
1279
  },
593
1280
  "required": ["command"]
594
1281
  }
1282
+ },
1283
+ {
1284
+ "name": "get_diagnostics",
1285
+ "description": "Retrieve syntax diagnostics and code errors for a file or the entire workspace.",
1286
+ "inputSchema": {
1287
+ "type": "object",
1288
+ "properties": {
1289
+ "path": {"type": "string", "description": "Path to the file or directory to inspect (defaults to '.')"}
1290
+ }
1291
+ }
1292
+ },
1293
+ {
1294
+ "name": "browser_action",
1295
+ "description": "Control a headless browser (Playwright) for web navigation, UI testing, and visual inspection via screenshots.",
1296
+ "inputSchema": {
1297
+ "type": "object",
1298
+ "properties": {
1299
+ "action": {
1300
+ "type": "string",
1301
+ "enum": ["navigate", "screenshot", "click", "fill"],
1302
+ "description": "Action to perform in the browser"
1303
+ },
1304
+ "url": {"type": "string", "description": "Target URL for navigate or screenshot"},
1305
+ "selector": {"type": "string", "description": "CSS selector for click or fill actions"},
1306
+ "value": {"type": "string", "description": "Text value to type for fill action"}
1307
+ },
1308
+ "required": ["action"]
1309
+ }
595
1310
  }
596
1311
  ]
597
1312
 
598
1313
  LOCAL_TOOL_HANDLERS = {
599
1314
  "list_directory": list_directory,
600
1315
  "read_file": read_file,
1316
+ "write_file": write_file,
601
1317
  "edit_file": edit_file,
602
- "execute_command": execute_command
1318
+ "bash": bash,
1319
+ "search_files": search_files,
1320
+ "execute_command": execute_command,
1321
+ "get_diagnostics": get_diagnostics,
1322
+ "browser_action": browser_action
603
1323
  }
604
1324
 
605
1325
  # ==============================================================================
@@ -616,18 +1336,51 @@ MAX_COMMAND_LENGTH = 16384
616
1336
  def validate_local_tool_arguments(tool_name: str, arguments: dict) -> str | None:
617
1337
  """Valida tipos y límites de las herramientas locales antes de ejecutarlas."""
618
1338
  schemas = {
619
- "list_directory": {"path": (str, MAX_PATH_LENGTH)},
1339
+ "list_directory": {
1340
+ "path": (str, MAX_PATH_LENGTH),
1341
+ "recursive": (bool, None)
1342
+ },
620
1343
  "read_file": {
621
- "path": (str, MAX_PATH_LENGTH), "start_line": (int, None),
622
- "max_lines": (int, None), "max_bytes": (int, None)
1344
+ "path": (str, MAX_PATH_LENGTH),
1345
+ "start_line": (int, None),
1346
+ "end_line": (int, None),
1347
+ "max_lines": (int, None),
1348
+ "max_bytes": (int, None)
1349
+ },
1350
+ "write_file": {
1351
+ "path": (str, MAX_PATH_LENGTH),
1352
+ "content": (str, MAX_HTTP_BODY_BYTES)
623
1353
  },
624
1354
  "edit_file": {
625
- "path": (str, MAX_PATH_LENGTH), "content": (str, MAX_HTTP_BODY_BYTES),
626
- "mode": (str, 32), "target_content": (str, MAX_HTTP_BODY_BYTES)
1355
+ "path": (str, MAX_PATH_LENGTH),
1356
+ "old_str": (str, MAX_HTTP_BODY_BYTES),
1357
+ "new_str": (str, MAX_HTTP_BODY_BYTES),
1358
+ "content": (str, MAX_HTTP_BODY_BYTES),
1359
+ "mode": (str, 32),
1360
+ "target_content": (str, MAX_HTTP_BODY_BYTES)
1361
+ },
1362
+ "bash": {
1363
+ "command": (str, MAX_COMMAND_LENGTH),
1364
+ "timeout_seconds": (int, None)
1365
+ },
1366
+ "search_files": {
1367
+ "query": (str, 4096),
1368
+ "path": (str, MAX_PATH_LENGTH),
1369
+ "file_pattern": (str, 256)
627
1370
  },
628
1371
  "execute_command": {
629
- "command": (str, MAX_COMMAND_LENGTH), "cwd": (str, MAX_PATH_LENGTH),
1372
+ "command": (str, MAX_COMMAND_LENGTH),
1373
+ "cwd": (str, MAX_PATH_LENGTH),
630
1374
  "timeout_seconds": (int, None)
1375
+ },
1376
+ "get_diagnostics": {
1377
+ "path": (str, MAX_PATH_LENGTH)
1378
+ },
1379
+ "browser_action": {
1380
+ "action": (str, 32),
1381
+ "url": (str, 4096),
1382
+ "selector": (str, 1024),
1383
+ "value": (str, MAX_HTTP_BODY_BYTES)
631
1384
  }
632
1385
  }
633
1386
  schema = schemas.get(tool_name)
@@ -639,19 +1392,38 @@ def validate_local_tool_arguments(tool_name: str, arguments: dict) -> str | None
639
1392
  if expected is None:
640
1393
  return f"Argumento no permitido: {name}"
641
1394
  expected_type, max_length = expected
642
- if isinstance(value, bool) or not isinstance(value, expected_type):
643
- return f"Tipo inválido para '{name}'"
1395
+ if expected_type is bool:
1396
+ if not isinstance(value, bool):
1397
+ return f"Tipo inválido para '{name}'"
1398
+ else:
1399
+ if isinstance(value, bool) or not isinstance(value, expected_type):
1400
+ return f"Tipo inválido para '{name}'"
644
1401
  if max_length is not None and len(value) > max_length:
645
1402
  return f"'{name}' excede el tamaño máximo permitido"
646
1403
 
647
1404
  required = {
648
1405
  "read_file": ("path",),
649
- "edit_file": ("path", "content"),
650
- "execute_command": ("command",)
1406
+ "write_file": ("path", "content"),
1407
+ "edit_file": ("path",),
1408
+ "bash": ("command",),
1409
+ "search_files": ("query",),
1410
+ "execute_command": ("command",),
1411
+ "browser_action": ("action",)
651
1412
  }
652
1413
  for name in required.get(tool_name, ()):
653
1414
  if name not in arguments:
654
1415
  return f"Falta el argumento obligatorio: {name}"
1416
+
1417
+ if tool_name == "edit_file":
1418
+ has_surgical = "old_str" in arguments and "new_str" in arguments
1419
+ has_legacy = "content" in arguments
1420
+ if not has_surgical and not has_legacy:
1421
+ return "Faltan argumentos obligatorios: especifica 'old_str' y 'new_str' o 'content'"
1422
+
1423
+ if tool_name == "browser_action":
1424
+ if arguments.get("action") not in ("navigate", "screenshot", "click", "fill"):
1425
+ return f"Acción de navegador no permitida: {arguments.get('action')}"
1426
+
655
1427
  return None
656
1428
 
657
1429
  def sanitize_log_path(raw_path: str) -> str:
@@ -1590,6 +2362,12 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
1590
2362
  return
1591
2363
  if content_len > MAX_HTTP_BODY_BYTES:
1592
2364
  self._log_req("POST", safe_path)
2365
+ if content_len <= MAX_HTTP_BODY_BYTES * 2:
2366
+ try:
2367
+ self.rfile.read(content_len)
2368
+ except Exception:
2369
+ pass
2370
+ self.close_connection = True
1593
2371
  self._send_json_response(413, {"error": "Request body too large"})
1594
2372
  self._log_res(413, safe_path, (time.monotonic() - t0) * 1000, "Cuerpo HTTP excede el límite")
1595
2373
  return
@@ -1946,6 +2724,28 @@ def open_browser(url: str) -> bool:
1946
2724
  )
1947
2725
 
1948
2726
 
2727
+ def launch_browser(url: str) -> bool:
2728
+ """Abre el navegador en la URL de la sesión y muestra información o diagnóstico en consola."""
2729
+ termux_detected = is_termux_environment()
2730
+ if termux_detected:
2731
+ console_log(f"[{time.strftime('%H:%M:%S')}] Termux detectado; se abrirá mediante termux-open-url.", flush=True)
2732
+ console_log(f"[{time.strftime('%H:%M:%S')}] Abriendo navegador en la interfaz configurada...", flush=True)
2733
+ try:
2734
+ if not open_browser(url):
2735
+ raise RuntimeError("El lanzador de navegador devolvió un resultado sin éxito.")
2736
+ return True
2737
+ except Exception as e:
2738
+ console_log(f"[{time.strftime('%H:%M:%S')}] No se pudo abrir el navegador automáticamente: {e}", flush=True)
2739
+ console_log(" Traza de diagnóstico:", flush=True)
2740
+ traceback.print_exc()
2741
+ manual_command = get_manual_browser_command(url)
2742
+ if manual_command:
2743
+ console_log(" Termux detectado. Prueba este comando exacto:", flush=True)
2744
+ console_log(f" {manual_command}", flush=True)
2745
+ return False
2746
+
2747
+
2748
+
1949
2749
  # ==============================================================================
1950
2750
  # Punto de Entrada Principal (CLI)
1951
2751
  # ==============================================================================
@@ -2026,25 +2826,11 @@ def main():
2026
2826
  print(f" Auto-cierre : Desactivado")
2027
2827
  print("=" * 64, flush=True)
2028
2828
 
2029
- CONSOLE_CONTROL = ConsoleControl(server, parser)
2829
+ CONSOLE_CONTROL = ConsoleControl(server, parser, target_url=target_url)
2030
2830
  CONSOLE_CONTROL.start()
2031
2831
 
2032
2832
  if not args.no_browser:
2033
- termux_detected = is_termux_environment()
2034
- if termux_detected:
2035
- console_log(f"[{time.strftime('%H:%M:%S')}] Termux detectado; se abrirá mediante termux-open-url.", flush=True)
2036
- console_log(f"[{time.strftime('%H:%M:%S')}] Abriendo navegador en la interfaz configurada...", flush=True)
2037
- try:
2038
- if not open_browser(target_url):
2039
- raise RuntimeError("El lanzador de navegador devolvió un resultado sin éxito.")
2040
- except Exception as e:
2041
- console_log(f"[{time.strftime('%H:%M:%S')}] No se pudo abrir el navegador automáticamente: {e}", flush=True)
2042
- console_log(" Traza de diagnóstico:", flush=True)
2043
- traceback.print_exc()
2044
- manual_command = get_manual_browser_command(target_url)
2045
- if manual_command:
2046
- console_log(" Termux detectado. Prueba este comando exacto:", flush=True)
2047
- console_log(f" {manual_command}", flush=True)
2833
+ launch_browser(target_url)
2048
2834
 
2049
2835
  if exit_on_close:
2050
2836
  require_initial = not args.no_browser
File without changes
File without changes
File without changes