From a068cb55636a3775fdbc4afeafaa2ce29e999988 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Wed, 1 Apr 2026 23:46:53 +0200 Subject: [PATCH 01/34] Add centralised Serena dashboard tray manager Add class SerenaDashboardTrayManager: A single tray app to manage all Serena instances (detached process which is informed of running instances via network requests) Tray icon functionality was consequently removed from SerenaDashboardViewer, which is now a plain viewer. --- src/serena/agent.py | 81 +++++--- src/serena/cli.py | 5 +- src/serena/dashboard.py | 446 +++++++++++++++++++++++++++++----------- 3 files changed, 382 insertions(+), 150 deletions(-) diff --git a/src/serena/agent.py b/src/serena/agent.py index 91431192..f14fd1eb 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -2,17 +2,16 @@ The Serena Model Context Protocol (MCP) Server """ -import multiprocessing import os import platform import subprocess import sys from collections.abc import Callable, Iterator, Sequence from contextlib import contextmanager +from datetime import datetime from logging import Logger from typing import TYPE_CHECKING, Optional, TypeVar -import webview from sensai.util import logging from sensai.util.logging import LogTime from sensai.util.string import dict_string @@ -30,7 +29,7 @@ from serena.config.serena_config import ( SerenaPaths, ToolInclusionDefinition, ) -from serena.dashboard import SerenaDashboardAPI, SerenaDashboardViewer +from serena.dashboard import SerenaDashboardAPI, SerenaDashboardTrayManager from serena.ls_manager import LanguageServerManager from serena.project import MemoriesManager, Project from serena.prompt_factory import SerenaPromptFactory @@ -364,6 +363,10 @@ class SerenaAgent: self._active_tools: AvailableTools self._update_active_tools() + # record the agent start time (used for tray manager registration) + self._agent_started_at = datetime.now().isoformat(timespec="seconds") + self._dashboard_port: int | None = None + # start the dashboard (web frontend), registering its log handler # should be the last thing to happen in the initialization since the dashboard # may access various parts of the agent @@ -371,13 +374,14 @@ class SerenaAgent: self._dashboard_thread, port = SerenaDashboardAPI( get_memory_log_handler(), tool_names, agent=self, tool_usage_stats=self._tool_usage_stats ).run_in_thread(host=self.serena_config.web_dashboard_listen_address) + self._dashboard_port = port dashboard_host = self.serena_config.web_dashboard_listen_address if dashboard_host == "0.0.0.0": dashboard_host = "localhost" dashboard_url = f"http://{dashboard_host}:{port}/dashboard/index.html" self._dashboard_url = dashboard_url log.info("Serena web dashboard started at %s", dashboard_url) - self._start_dashboard_viewer(minimized=not self.serena_config.web_dashboard_open_on_launch) + self._ensure_tray_manager_and_register(open_on_launch=self.serena_config.web_dashboard_open_on_launch) # inform the GUI window (if any) if self._gui_log_viewer is not None: self._gui_log_viewer.set_dashboard_url(dashboard_url) @@ -520,39 +524,42 @@ class SerenaAgent: """ return self._dashboard_url - @staticmethod - def _start_dashboard_viewer_process_function(url: str, minimized: bool) -> None: + def _ensure_tray_manager_and_register(self, open_on_launch: bool) -> None: """ - Main function of the subprocess for starting the dashboard viewer - """ - try: - SerenaDashboardViewer(url, start_minimized=minimized).run() - except webview.errors.WebViewException as e: - log.warning(f"Could not open Serena Dashboard viewer. Cause:\n{e}") - # Fall back to opening the browser window if the window was supposed to be shown directly - if not minimized: - SerenaAgent._open_dashboard_in_browser(url) + Ensure the tray manager is running and register this dashboard instance with it. - def _start_dashboard_viewer(self, minimized: bool) -> None: - """ - Starts the dashboard viewer (in a separate process) or, if the current platform does not support it, - opens the dashboard in the default web browser. + If the current platform supports the native tray manager, this method starts + the manager (if not already running) and registers the instance. Otherwise, + it falls back to opening the dashboard in the default web browser. - :param minimized: whether the dashboard viewer should be started minimized (if supported on the current platform). - If the viewer is not supported on the current platform, then this controls whether to open the browser window. + :param open_on_launch: whether the dashboard should be opened immediately """ if not system_has_usable_display(): - log.info("Not starting the Serena dashboard viewer because no usable display was detected.") + log.info("Not starting the Serena tray manager because no usable display was detected.") return url = self.get_dashboard_url() assert url is not None - if SerenaDashboardViewer.is_current_platform_supported(): - process = multiprocessing.Process(target=self._start_dashboard_viewer_process_function, args=(url, minimized), daemon=True) - process.start() + assert self._dashboard_port is not None + + if SerenaDashboardTrayManager.is_current_platform_supported(): + # ensure the singleton tray manager process is running + SerenaDashboardTrayManager.ensure_running() + + # determine the current project name (if any) + project_name = self._active_project.project_name if self._active_project is not None else None + + # register this instance with the tray manager, optionally opening a viewer + SerenaDashboardTrayManager.register_instance( + port=self._dashboard_port, + dashboard_url=url, + project=project_name, + started_at=self._agent_started_at, + open_viewer=open_on_launch, + ) else: - log.info("Not starting Serena dashboard viewer because the current platform does not support it; using browser-based fallback") - if not minimized: + log.info("Not starting Serena tray manager because the current platform does not support it; using browser-based fallback") + if open_on_launch: self._open_dashboard_in_browser(url) def open_dashboard(self) -> bool: @@ -765,6 +772,11 @@ class SerenaAgent: if self._project_activation_callback is not None: self._project_activation_callback() + # notify the tray manager of the project change (if the dashboard is running) + dashboard_port = getattr(self, "_dashboard_port", None) + if dashboard_port is not None and SerenaDashboardTrayManager.is_current_platform_supported(): + SerenaDashboardTrayManager.update_project(dashboard_port, project.project_name) + def activate_project_from_path_or_name( self, project_root_or_name: str, update_active_modes: bool = True, update_active_tools: bool = True ) -> Project: @@ -890,14 +902,19 @@ class SerenaAgent: """ Shuts down the agent, freeing resources and stopping background tasks. """ - # guard against __del__ being called on a partially constructed instance - if not hasattr(self, "_active_project"): - return log.info("SerenaAgent is shutting down ...") - if self._active_project is not None: + + # NOTE: This is called from __del__, so the instance may not be fully initialized (exception during __init__) + + if hasattr(self, "_dashboard_port"): + if self._dashboard_port is not None and SerenaDashboardTrayManager.is_current_platform_supported(): + SerenaDashboardTrayManager.unregister_instance(self._dashboard_port) + + if hasattr(self, "_active_project") and self._active_project is not None: self._active_project.shutdown(timeout=timeout) self._active_project = None - if self._gui_log_viewer: + + if hasattr(self, "_gui_log_viewer") and self._gui_log_viewer is not None: log.info("Stopping the GUI log window ...") self._gui_log_viewer.stop() self._gui_log_viewer = None diff --git a/src/serena/cli.py b/src/serena/cli.py index 7ad3fb2d..2bb7dbdf 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -415,11 +415,10 @@ class TopLevelCommands(AutoRegisteringGroup): @click.argument("url", type=str) @click.option("--width", type=int, default=1400, show_default=True, help="Window width.") @click.option("--height", type=int, default=900, show_default=True, help="Window height.") - @click.option("--minimized", is_flag=True, default=False, help="Whether to start minimized/in tray.") - def dashboard_viewer(url: str, width: int, height: int, minimized: bool) -> None: + def dashboard_viewer(url: str, width: int, height: int) -> None: from serena.dashboard import SerenaDashboardViewer - viewer = SerenaDashboardViewer(url, start_minimized=minimized, width=width, height=height) + viewer = SerenaDashboardViewer(url, width=width, height=height) viewer.run() diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index 9a8f3f50..17656e37 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -1,8 +1,13 @@ +import json import os import socket +import subprocess import sys import threading +import time +import urllib.request from collections.abc import Callable +from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, Any, Self @@ -683,42 +688,34 @@ class SerenaDashboardAPI: class SerenaDashboardViewer: """ - Minimal pywebview wrapper with optional system tray. + Minimal pywebview wrapper that opens a dashboard in a native window. + + The viewer is a plain window without a system tray icon. Tray management + is handled separately by :class:`SerenaDashboardTrayManager`. """ def __init__( self, url: str, *, - start_minimized: bool = False, width: int = 1400, height: int = 900, ): self.url = url - # Use system tray to allow hiding to tray and intercepting close to hide instead of quit - self.tray = True self.title = "Serena Dashboard" self.width = width self.height = height - self.start_minimized = start_minimized - - self.window: webview.Window - self._tray_icon: Any - self._quitting = False - self._app_icon_path: str | None = None @staticmethod def is_current_platform_supported() -> bool: """ - :return: whether the current platform supports the dashboard viewer + :return: whether the current platform supports the native dashboard viewer (and tray manager). """ - # The dashboard viewer (with system tray) is technically supported only on Windows and macOS. - # Linux support is problematic; see https://github.com/oraios/serena/pull/1117#issuecomment-4128753943 + # supported on Windows and macOS; Linux support is problematic + # (see https://github.com/oraios/serena/pull/1117#issuecomment-4128753943) supported_platforms = [ "win32", - # NOTE: Disabling macOS support for now, because the tray behaviour is suboptimal (too many icons when - # subagents are spawned, etc.) - # "darwin" + "darwin", ] return sys.platform in supported_platforms @@ -733,145 +730,364 @@ class SerenaDashboardViewer: # .ico is Windows-only; macOS expects a PNG for the window/dock icon. icon_filename = "serena.ico" if sys.platform == "win32" else "serena-icon-1024-mac.png" icon_path = str(dashboard_path / icon_filename) - self._app_icon_path = icon_path - # Create hidden to avoid flash; show/restore/minimize in start callback. window = webview.create_window( self.title, self.url, width=self.width, height=self.height, - hidden=self.start_minimized, ) assert window is not None - self.window = window - if self.tray: - self.window.events.closing += self._on_closing - self._start_tray() + webview.start(icon=icon_path) - def _start_callback() -> None: - if self.start_minimized: - self._hide_window() - else: - self._show_window() - webview.start(_start_callback, icon=icon_path) +@dataclass +class TrayManagedInstance: + """A registered Serena dashboard instance managed by the tray manager.""" - def _show_window(self) -> None: - if not self.window: - return + port: int + """the port on which the dashboard API is listening""" - if sys.platform == "darwin": - from PyObjCTools.AppHelper import callAfter + dashboard_url: str + """the full URL to the dashboard frontend""" - callAfter(self._show_window_on_macos) - else: - self.window.show() - self.window.restore() + project: str | None + """the name of the active project, or None if no project is activated""" - def _hide_window(self) -> None: - if not self.window: - return + started_at: str + """ISO 8601 timestamp of when the agent instance was started""" - if sys.platform == "darwin": - from PyObjCTools.AppHelper import callAfter - callAfter(self._hide_window_on_macos) - else: - self.window.hide() +class SerenaDashboardTrayManager: + """ + Singleton process managing a system tray icon for all Serena dashboard instances. - def _show_window_on_macos(self) -> None: - from AppKit import ( - NSApplication, - NSApplicationActivationPolicyRegular, - ) - from PyObjCTools.AppHelper import callLater + Runs a Flask backend on a fixed port and displays a single tray icon that + aggregates all running Serena instances. Individual dashboard viewers are + spawned on demand when the user clicks a menu item. - ns_app = NSApplication.sharedApplication() - ns_app.setActivationPolicy_(NSApplicationActivationPolicyRegular) - self._set_macos_app_icon(ns_app) - ns_app.unhide_(None) - ns_app.activateIgnoringOtherApps_(True) - # Give the status item menu a beat to close before restoring the window. - callLater(0.1, self._restore_window_on_macos) + The manager is started as a detached process by the first Serena agent that + needs it and terminates automatically when no dashboard instances remain. + """ - def _restore_window_on_macos(self) -> None: - self.window.show() - self.window.restore() + PORT = 0x5ED8 + """fixed port for the tray manager (dashboard base port 0x5EDA minus 2)""" - def _hide_window_on_macos(self) -> None: - from AppKit import ( - NSApplication, - NSApplicationActivationPolicyAccessory, - ) + HOST = "127.0.0.1" + """listen address (local only)""" - self.window.hide() - NSApplication.sharedApplication().setActivationPolicy_(NSApplicationActivationPolicyAccessory) + ALIVE_CHECK_INTERVAL_SECONDS = 15 + """interval in seconds between alive checks of registered instances""" - def _set_macos_app_icon(self, ns_app: Any) -> None: - if not self._app_icon_path: - return + def __init__(self) -> None: + self._instances: dict[int, TrayManagedInstance] = {} + self._lock = threading.Lock() + self._tray_icon: Any = None + self._app = Flask(__name__) + self._setup_routes() - from AppKit import NSImage + def _setup_routes(self) -> None: + @self._app.route("/health", methods=["GET"]) + def health() -> dict[str, str]: + return {"status": "alive"} - ns_image = NSImage.alloc().initByReferencingFile_(self._app_icon_path) - if ns_image is not None: - ns_app.setApplicationIconImage_(ns_image) + @self._app.route("/register", methods=["POST"]) + def register() -> dict[str, str]: + data = request.get_json() + instance = TrayManagedInstance( + port=data["port"], + dashboard_url=data["dashboard_url"], + project=data.get("project"), + started_at=data["started_at"], + ) + with self._lock: + self._instances[instance.port] = instance + log.info("Registered instance on port %d (project=%s)", instance.port, instance.project) - def _on_closing(self) -> bool: - """Intercept window close: hide window instead of quitting (macOS standard behavior).""" - if self._quitting: - return True - self._hide_window() - return False # prevent the window from actually closing + # open a viewer immediately if requested + if data.get("open_viewer", False): + self._open_viewer(instance) - def _start_tray(self) -> None: - # import pystray locally, because the import fails when there is no display! - import pystray + return {"status": "registered"} + + @self._app.route("/update_project", methods=["POST"]) + def update_project() -> dict[str, str]: + data = request.get_json() + port = data["port"] + project = data.get("project") + with self._lock: + if port in self._instances: + self._instances[port].project = project + log.info("Updated project for instance on port %d to '%s'", port, project) + return {"status": "updated"} + + @self._app.route("/unregister", methods=["POST"]) + def unregister() -> dict[str, str]: + data = request.get_json() + port = data["port"] + with self._lock: + self._instances.pop(port, None) + log.info("Unregistered instance on port %d", port) + return {"status": "unregistered"} + + def _build_menu_items(self) -> tuple[Any, ...]: + """ + Callable that returns the current tray menu items. + + Invoked dynamically by pystray each time the menu is shown. + When there is exactly one instance, it is marked as the default action + so that a left-click on the tray icon opens the viewer immediately. + When there are multiple instances, a hidden default item forces the menu + to appear on left-click (Windows only; macOS always shows the menu). + """ from pystray import MenuItem as Item - from pystray._base import Icon as TrayIcon + + with self._lock: + instances = list(self._instances.values()) + + if not instances: + return (Item("No instances", None, enabled=False),) + + # determine whether a single-instance shortcut applies + is_single = len(instances) == 1 + + items: list[Any] = [] + + # for multi-instance: add a hidden default item that opens the menu on left-click + if not is_single: + + def _force_show_menu(icon: Any, _item: Any) -> None: + if hasattr(icon, "_show_menu"): + icon._show_menu() + + items.append(Item("Instances", _force_show_menu, default=True, visible=False)) + + for inst in sorted(instances, key=lambda i: i.started_at): + label = f"{inst.project or 'SerenaAgent'} ({inst.started_at})" + + # closure to capture the current instance + def _make_callback(instance: TrayManagedInstance) -> Callable: + def _callback(_icon: Any, _item: Any) -> None: + self._open_viewer(instance) + + return _callback + + items.append(Item(label, _make_callback(inst), default=is_single)) + + return tuple(items) + + def _open_viewer(self, instance: TrayManagedInstance) -> None: + """Spawn a dashboard viewer window for the given instance in a new subprocess.""" + url = instance.dashboard_url + + kwargs: dict[str, Any] = { + "stdin": subprocess.DEVNULL, + "stdout": subprocess.DEVNULL, + "stderr": subprocess.DEVNULL, + } + if sys.platform == "win32": + CREATE_NO_WINDOW = 0x08000000 + kwargs["creationflags"] = CREATE_NO_WINDOW + + subprocess.Popen( + [sys.executable, "-c", f"from serena.dashboard import SerenaDashboardViewer; SerenaDashboardViewer({url!r}).run()"], + **kwargs, + ) + + def _alive_check_loop(self) -> None: + """Periodically check whether registered instances are still reachable. + + Removes unreachable instances and terminates the manager when none remain. + """ + while True: + time.sleep(self.ALIVE_CHECK_INTERVAL_SECONDS) + + # collect ports to check + with self._lock: + ports_to_check = list(self._instances.keys()) + + # probe each instance + dead_ports: list[int] = [] + for port in ports_to_check: + try: + url = f"http://127.0.0.1:{port}/heartbeat" + req = urllib.request.Request(url, method="GET") + urllib.request.urlopen(req, timeout=3) + except Exception: + dead_ports.append(port) + + # remove dead instances + if dead_ports: + with self._lock: + for port in dead_ports: + self._instances.pop(port, None) + log.info("Removed unreachable instance on port %d", port) + + # terminate if no instances remain + with self._lock: + remaining = len(self._instances) + if remaining == 0: + log.info("No dashboard instances remaining; shutting down tray manager") + if self._tray_icon is not None: + self._tray_icon.stop() + return + + def run(self) -> None: + """Run the tray manager (blocking). Starts Flask, alive-check thread, and tray icon.""" + import pystray dashboard_path = Path(SERENA_DASHBOARD_DIR) - # macOS menu bar icons are displayed at 16pt; 32px covers Retina (@2x). - # Windows/Linux tray icons are larger, so 48px is the better fit there. + # select the appropriate icon for the platform icon_filename = "serena-icon-tray-mac.png" if sys.platform == "darwin" else "serena-icon-48.png" icon_img = Image.open(dashboard_path / icon_filename) - def show(_icon: TrayIcon, _item: Item) -> None: - self._show_window() - - def hide(_icon: TrayIcon, _item: Item) -> None: - self._hide_window() - - def quit_app(_icon: TrayIcon, _item: Item) -> None: - self._quitting = True - try: - _icon.stop() - finally: - if self.window: - self.window.destroy() - - menu = pystray.Menu( - Item("Open", show, default=True), - Item("Hide", hide), - Item("Quit", quit_app), + # start Flask in a background thread + flask_thread = threading.Thread( + target=lambda: self._app.run(host=self.HOST, port=self.PORT, debug=False, use_reloader=False, threaded=True), + daemon=True, ) + flask_thread.start() + # start alive-check in a background thread + alive_thread = threading.Thread(target=self._alive_check_loop, daemon=True) + alive_thread.start() + + # set up tray icon with a dynamic menu (callable returns items on each open) kwargs: dict[str, Any] = {} if sys.platform == "darwin": - # Passing darwin_nsapplication integrates pystray with the NSApplication - # run loop that webview.start() is about to enter. sharedApplication() - # is idempotent; pywebview will reuse the same singleton. from AppKit import NSApplication kwargs["darwin_nsapplication"] = NSApplication.sharedApplication() - self._tray_icon = pystray.Icon("dashboard_viewer", icon_img, self.title, menu, **kwargs) + self._tray_icon = pystray.Icon( + "serena_tray_manager", + icon_img, + "Serena", + menu=pystray.Menu(self._build_menu_items), + **kwargs, + ) - # On Windows/Linux, run_detached spawns pystray's own internal thread and - # returns immediately. On macOS it hooks into the NSApplication run loop - # that webview.start() is about to enter (run_detached is always called - # before webview.start() on macOS — see run()). - self._tray_icon.run_detached() + # blocks until stop() is called + self._tray_icon.run() + + # ------------------------------------------------------------------ + # Class-level helpers (used by agents to interact with the manager) + # ------------------------------------------------------------------ + + @classmethod + def is_current_platform_supported(cls) -> bool: + """ + :return: whether the current platform supports the tray manager + """ + supported_platforms = ["win32", "darwin"] + return sys.platform in supported_platforms + + @classmethod + def is_running(cls) -> bool: + """ + :return: True if a tray manager process is already listening on the fixed port + """ + try: + url = f"http://{cls.HOST}:{cls.PORT}/health" + req = urllib.request.Request(url, method="GET") + urllib.request.urlopen(req, timeout=2) + return True + except Exception: + return False + + @classmethod + def ensure_running(cls) -> None: + """Ensure a tray manager process is running, starting one if necessary.""" + log.info("Ensuring dashboard tray manager availability") + if cls.is_running(): + log.info("Dashboard tray manager is already running") + return + + # spawn a detached process + log.info("Starting new dashboard tray manager process") + cmd = [ + sys.executable, + "-c", + "from serena.dashboard import SerenaDashboardTrayManager; SerenaDashboardTrayManager().run()", + ] + kwargs: dict[str, Any] = { + "stdin": subprocess.DEVNULL, + "stdout": subprocess.DEVNULL, + "stderr": subprocess.DEVNULL, + } + if sys.platform == "win32": + # CREATE_NO_WINDOW suppresses the console; CREATE_NEW_PROCESS_GROUP + # isolates the child from the parent's Ctrl+C group. + # Note: DETACHED_PROCESS must NOT be combined with CREATE_NO_WINDOW. + CREATE_NEW_PROCESS_GROUP = 0x00000200 + CREATE_NO_WINDOW = 0x08000000 + kwargs["creationflags"] = CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW + else: + kwargs["start_new_session"] = True + + subprocess.Popen(cmd, **kwargs) + + # wait for the manager to become available + for _ in range(30): + time.sleep(0.1) + if cls.is_running(): + log.info("Dashboard tray manager started successfully") + return + log.warning("Dashboard tray manager did not start within the expected time") + + @classmethod + def register_instance(cls, port: int, dashboard_url: str, project: str | None, started_at: str, open_viewer: bool = False) -> None: + """Register a dashboard instance with the running tray manager. + + :param port: the port of the dashboard API (used for alive checks) + :param dashboard_url: the full URL to the dashboard frontend + :param project: the currently active project name, or None + :param started_at: ISO 8601 timestamp of when the agent was started + :param open_viewer: whether the tray manager should immediately open a viewer for this instance + """ + url = f"http://{cls.HOST}:{cls.PORT}/register" + data = json.dumps( + { + "port": port, + "dashboard_url": dashboard_url, + "project": project, + "started_at": started_at, + "open_viewer": open_viewer, + } + ).encode() + req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST") + try: + urllib.request.urlopen(req, timeout=2) + except Exception as e: + log.warning("Failed to register with tray manager: %s", e) + + @classmethod + def update_project(cls, port: int, project: str | None) -> None: + """Notify the tray manager of a project change for the given instance. + + :param port: the port of the dashboard API + :param project: the new active project name, or None + """ + url = f"http://{cls.HOST}:{cls.PORT}/update_project" + data = json.dumps({"port": port, "project": project}).encode() + req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST") + try: + urllib.request.urlopen(req, timeout=2) + except Exception as e: + log.warning("Failed to update project with tray manager: %s", e) + + @classmethod + def unregister_instance(cls, port: int) -> None: + """Unregister a dashboard instance from the tray manager. + + :param port: the port of the dashboard API to unregister + """ + url = f"http://{cls.HOST}:{cls.PORT}/unregister" + data = json.dumps({"port": port}).encode() + req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST") + try: + urllib.request.urlopen(req, timeout=2) + except Exception as e: + log.warning("Failed to unregister from tray manager: %s", e) From d180d687569b5973befe9a8ef750cc19a55c6fb1 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 2 Apr 2026 00:19:57 +0200 Subject: [PATCH 02/34] Add SerenaPorts to centralise port configuration in one place --- src/serena/constants.py | 7 +++++++ src/serena/dashboard.py | 9 +++++---- src/serena/jetbrains/jetbrains_plugin_client.py | 3 ++- src/serena/project_server.py | 4 ++-- 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/serena/constants.py b/src/serena/constants.py index 4d96bcff..db4d1b35 100644 --- a/src/serena/constants.py +++ b/src/serena/constants.py @@ -32,3 +32,10 @@ SERENA_LOG_FORMAT = "%(levelname)-5s %(asctime)-15s [%(threadName)s] %(name)s:%( LOG_MESSAGES_BUFFER_SIZE = 2500 """The maximum number of log messages to keep in the buffer (for the dashboard).""" + + +class SerenaPorts: + TRAY_MANAGER_PORT = 0x5EA0 + PROJECT_SERVER_PORT = 0x5EA1 + JETBRAINS_PLUGIN_SERVER_BASE_PORT = 0x5EA2 + DASHBOARD_API_BASE_PORT = 0x5EDA diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index 17656e37..1ac9194e 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -19,7 +19,7 @@ from sensai.util import logging from serena.analytics import ToolUsageStats from serena.config.serena_config import SerenaConfig, SerenaPaths -from serena.constants import SERENA_DASHBOARD_DIR +from serena.constants import SERENA_DASHBOARD_DIR, SerenaPorts from serena.task_executor import TaskExecutor from serena.util.logging import MemoryLogHandler @@ -133,6 +133,8 @@ class QueuedExecution(BaseModel): class SerenaDashboardAPI: + BASE_PORT = SerenaPorts.DASHBOARD_API_BASE_PORT + log = logging.getLogger(__qualname__) def __init__( @@ -679,7 +681,7 @@ class SerenaDashboardAPI: return port def run_in_thread(self, host: str) -> tuple[threading.Thread, int]: - port = self._find_first_free_port(0x5EDA, host) + port = self._find_first_free_port(self.BASE_PORT, host) log.info("Starting dashboard (listen_address=%s, port=%d)", host, port) thread = threading.Thread(target=lambda: self.run(host=host, port=port), daemon=True) thread.start() @@ -771,8 +773,7 @@ class SerenaDashboardTrayManager: needs it and terminates automatically when no dashboard instances remain. """ - PORT = 0x5ED8 - """fixed port for the tray manager (dashboard base port 0x5EDA minus 2)""" + PORT = SerenaPorts.TRAY_MANAGER_PORT HOST = "127.0.0.1" """listen address (local only)""" diff --git a/src/serena/jetbrains/jetbrains_plugin_client.py b/src/serena/jetbrains/jetbrains_plugin_client.py index f237a079..6d5fc3da 100644 --- a/src/serena/jetbrains/jetbrains_plugin_client.py +++ b/src/serena/jetbrains/jetbrains_plugin_client.py @@ -18,6 +18,7 @@ from sensai.util.string import ToStringMixin import serena.jetbrains.jetbrains_types as jb from serena.config.serena_config import RegisteredProject +from serena.constants import SerenaPorts from serena.jetbrains.jetbrains_types import PluginStatusDTO from serena.project import Project from serena.util.class_decorators import singleton @@ -174,7 +175,7 @@ class JetBrainsPluginClient(ToStringMixin): Provides simple methods to interact with all available endpoints. """ - BASE_PORT = 0x5EA2 + BASE_PORT = SerenaPorts.JETBRAINS_PLUGIN_SERVER_BASE_PORT PLUGIN_REQUEST_TIMEOUT = 300 """ the timeout used for request handling within the plugin (a constant in the plugin) diff --git a/src/serena/project_server.py b/src/serena/project_server.py index ef88e883..dfe5c291 100644 --- a/src/serena/project_server.py +++ b/src/serena/project_server.py @@ -8,7 +8,7 @@ from pydantic import BaseModel from sensai.util.logging import LogTime from serena.config.serena_config import LanguageBackend, SerenaConfig -from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClient +from serena.constants import SerenaPorts if TYPE_CHECKING: from serena.project import Project @@ -42,7 +42,7 @@ class ProjectServer: :class:`~serena.tools.query_project_tools.QueryProjectTool`. """ - PORT = JetBrainsPluginClient.BASE_PORT - 1 + PORT = SerenaPorts.PROJECT_SERVER_PORT def __init__(self) -> None: from serena.agent import SerenaAgent From adce15336db2b0a39f6fd8bf8b01f6887f8e45d0 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 2 Apr 2026 00:35:37 +0200 Subject: [PATCH 03/34] Use multiprocessing instead of subprocess to open dashboard viewers --- src/serena/dashboard.py | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index 1ac9194e..6141d807 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -879,22 +879,20 @@ class SerenaDashboardTrayManager: return tuple(items) def _open_viewer(self, instance: TrayManagedInstance) -> None: - """Spawn a dashboard viewer window for the given instance in a new subprocess.""" - url = instance.dashboard_url + """Spawn a dashboard viewer window for the given instance in a new process.""" + import multiprocessing - kwargs: dict[str, Any] = { - "stdin": subprocess.DEVNULL, - "stdout": subprocess.DEVNULL, - "stderr": subprocess.DEVNULL, - } - if sys.platform == "win32": - CREATE_NO_WINDOW = 0x08000000 - kwargs["creationflags"] = CREATE_NO_WINDOW - - subprocess.Popen( - [sys.executable, "-c", f"from serena.dashboard import SerenaDashboardViewer; SerenaDashboardViewer({url!r}).run()"], - **kwargs, + process = multiprocessing.Process( + target=self._run_viewer, + args=(instance.dashboard_url,), + daemon=True, ) + process.start() + + @staticmethod + def _run_viewer(url: str) -> None: + """Process target for running a dashboard viewer window.""" + SerenaDashboardViewer(url).run() def _alive_check_loop(self) -> None: """Periodically check whether registered instances are still reachable. From a386ec41c441de15885174d0bde37eaee30c76b8 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 17 Apr 2026 21:31:21 +0200 Subject: [PATCH 04/34] Introduce DashboardManager with distinct modes to control behaviour across platforms --- src/serena/agent.py | 278 +++++++++++++++++++++++++------------------- 1 file changed, 160 insertions(+), 118 deletions(-) diff --git a/src/serena/agent.py b/src/serena/agent.py index 7e9509d0..9b37a04b 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -12,6 +12,7 @@ import sys from collections.abc import Callable, Iterator, Sequence from contextlib import contextmanager from datetime import datetime +from enum import Enum from logging import Logger from typing import TYPE_CHECKING, Optional, TypeVar @@ -246,6 +247,147 @@ class ActiveModes: return [SerenaAgentMode.load(mode_name) for mode_name in self._base_modes or []] +class DashboardManager: + class Mode(Enum): + BROWSER = "browser" + """ + Open the dashboard in the default browser; supported on all platforms. + """ + PYWEBVIEW = "webview" + """ + Open the dashboard via a native window (using pywebview) which minimises to the tray; supported on Windows only + (on macOS, tray apps for multiple instances accumulate in the top bar, which users do not want) + """ + TRAY_MANAGER = "tray_manager" + """ + Register dashboard instance with a central manager tray app (single tray icon for all instances), + spawning the tray manager if not already running; supported on macOS and Windows. + """ + + @classmethod + def from_platform(cls) -> "DashboardManager.Mode": + match platform.system(): + case "Windows": + return cls.PYWEBVIEW + case "Darwin": + return cls.TRAY_MANAGER + case _: + return cls.BROWSER + + def __init__(self, port: int, host_listen_address: str, open_dashboard_on_launch: bool, active_project: Project | None = None): + self._port = port + self._mode = self.Mode.from_platform() + self._dashboard_viewer_process: multiprocessing.Process | None = None + + dashboard_host = host_listen_address + if dashboard_host == "0.0.0.0": + dashboard_host = "localhost" + self.url = f"http://{dashboard_host}:{port}/dashboard/index.html" + + # handle startup + match self._mode: + case self.Mode.PYWEBVIEW: + self._start_dashboard_viewer(minimized=not open_dashboard_on_launch) + case self.Mode.TRAY_MANAGER: + self._tray_manager_register(open_on_launch=open_dashboard_on_launch, active_project=active_project) + case self.Mode.BROWSER: + if open_dashboard_on_launch: + if not system_has_usable_display(): + log.info("Not opening the Serena dashboard because no usable display was detected.") + else: + self.open_dashboard_in_browser() + + @staticmethod + def _open_url_in_browser(url: str) -> None: + # Use a subprocess to avoid any output from webbrowser.open being written to stdout + subprocess.Popen( + [sys.executable, "-c", f"import webbrowser; webbrowser.open({url!r})"], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=False, + ) + + def open_dashboard_in_browser(self) -> None: + return self._open_url_in_browser(self.url) + + @staticmethod + def _start_dashboard_viewer_process_function(url: str, minimized: bool, parent_process_id: int) -> None: + """ + Main function of the subprocess for starting the dashboard viewer + """ + try: + SerenaDashboardViewer(url, start_minimized=minimized, parent_process_id=parent_process_id).run() + except webview.errors.WebViewException as e: + log.warning(f"Could not open Serena Dashboard viewer. Cause:\n{e}") + # Fall back to opening the browser window if the window was supposed to be shown directly + if not minimized: + open_url_in_browser(url) + + def _start_dashboard_viewer(self, minimized: bool) -> None: + """ + Starts the dashboard viewer (in a separate process) or, if the current platform does not support it, + opens the dashboard in the default web browser. + + :param minimized: whether the dashboard viewer should be started minimized (if supported on the current platform). + If the viewer is not supported on the current platform, then this controls whether to open the browser window. + """ + self._dashboard_viewer_process = multiprocessing.Process( + target=self._start_dashboard_viewer_process_function, args=(self.url, minimized, os.getpid()), daemon=True + ) + self._dashboard_viewer_process.start() + + def _tray_manager_register(self, open_on_launch: bool, active_project: Project | None) -> None: + """ + Ensure the tray manager is running and register this dashboard instance with it. + + If the current platform supports the native tray manager, this method starts + the manager (if not already running) and registers the instance. Otherwise, + it falls back to opening the dashboard in the default web browser. + + :param open_on_launch: whether the dashboard should be opened immediately + """ + if not system_has_usable_display(): + log.info("Not starting the Serena tray manager because no usable display was detected.") + return + + # ensure the singleton tray manager process is running + SerenaDashboardTrayManager.ensure_running() + + # determine the current project name (if any) + project_name = active_project.project_name if active_project is not None else None + + # register this instance with the tray manager + SerenaDashboardTrayManager.register_instance( + port=self._port, + dashboard_url=self.url, + project=project_name, + started_at=datetime.now().isoformat(timespec="seconds"), + open_viewer=open_on_launch, + ) + + def shutdown(self) -> None: + """ + Frees resources, terminating the dashboard viewer process (if any) and unregistering from the tray manager (if applicable). + """ + if self._dashboard_viewer_process is not None: + log.info("Stopping the dashboard viewer process ...") + self._dashboard_viewer_process.terminate() + self._dashboard_viewer_process = None + if self._mode == self.Mode.TRAY_MANAGER: + SerenaDashboardTrayManager.unregister_instance(port=self._port) + + def update_active_project(self, active_project: Project | None) -> None: + """ + Updates the active project (where applicable). + + :param active_project: the currently active project or None if no project is active + """ + if self._mode == self.Mode.TRAY_MANAGER: + project_name = active_project.project_name if active_project is not None else None + SerenaDashboardTrayManager.update_project(port=self._port, project=project_name) + + class SerenaAgent: def __init__( self, @@ -269,8 +411,9 @@ class SerenaAgent: if necessary. """ self._active_project: Project | None = None + self._project_activation_callback = project_activation_callback self._gui_log_viewer: Optional["GuiLogViewer"] = None - self._dashboard_viewer_process: multiprocessing.Process | None = None + self._dashboard_manager: DashboardManager | None = None self.version = serena_version() @@ -285,9 +428,6 @@ class SerenaAgent: self.serena_config.get_registered_project(project, autoregister=True) if project is not None else None ) - # dashboard URL (set when dashboard is started) - self._dashboard_url: str | None = None - # adjust log level serena_log_level = self.serena_config.log_level if Logger.root.level != serena_log_level: @@ -365,7 +505,6 @@ class SerenaAgent: # Initialize the prompt factory self.prompt_factory = SerenaPromptFactory() - self._project_activation_callback = project_activation_callback # activate the given project (if any), also updating the active modes # Note: We cannot update the active tools yet, because the base toolset has not been computed yet @@ -390,10 +529,6 @@ class SerenaAgent: self._active_tools: AvailableTools self._update_active_tools() - # record the agent start time (used for tray manager registration) - self._agent_started_at = datetime.now().isoformat(timespec="seconds") - self._dashboard_port: int | None = None - # start the dashboard (web frontend), registering its log handler # should be the last thing to happen in the initialization since the dashboard # may access various parts of the agent @@ -401,17 +536,13 @@ class SerenaAgent: self._dashboard_thread, port = SerenaDashboardAPI( get_memory_log_handler(), tool_names, agent=self, tool_usage_stats=self._tool_usage_stats ).run_in_thread(host=self.serena_config.web_dashboard_listen_address) - self._dashboard_port = port - dashboard_host = self.serena_config.web_dashboard_listen_address - if dashboard_host == "0.0.0.0": - dashboard_host = "localhost" - dashboard_url = f"http://{dashboard_host}:{port}/dashboard/index.html" - self._dashboard_url = dashboard_url - log.info("Serena web dashboard started at %s", dashboard_url) - self._ensure_tray_manager_and_register(open_on_launch=self.serena_config.web_dashboard_open_on_launch) + self._dashboard_manager = DashboardManager( + port, self.serena_config.web_dashboard_listen_address, self.serena_config.web_dashboard_open_on_launch, self._active_project + ) + log.info("Serena web dashboard started at %s", self._dashboard_manager.url) # inform the GUI window (if any) if self._gui_log_viewer is not None: - self._gui_log_viewer.set_dashboard_url(dashboard_url) + self._gui_log_viewer.set_dashboard_url(self._dashboard_manager.url) self._send_usage_info() @@ -578,82 +709,9 @@ class SerenaAgent: """ :return: the URL of the web dashboard, or None if the dashboard is not running """ - return self._dashboard_url - - @staticmethod - def _start_dashboard_viewer_process_function(url: str, minimized: bool, parent_process_id: int) -> None: - """ - Main function of the subprocess for starting the dashboard viewer - """ - try: - SerenaDashboardViewer(url, start_minimized=minimized, parent_process_id=parent_process_id).run() - except webview.errors.WebViewException as e: - log.warning(f"Could not open Serena Dashboard viewer. Cause:\n{e}") - # Fall back to opening the browser window if the window was supposed to be shown directly - if not minimized: - SerenaAgent._open_dashboard_in_browser(url) - - def _start_dashboard_viewer(self, minimized: bool) -> None: - """ - Starts the dashboard viewer (in a separate process) or, if the current platform does not support it, - opens the dashboard in the default web browser. - - :param minimized: whether the dashboard viewer should be started minimized (if supported on the current platform). - If the viewer is not supported on the current platform, then this controls whether to open the browser window. - """ - if not system_has_usable_display(): - log.info("Not starting the Serena dashboard viewer because no usable display was detected.") - return - - url = self.get_dashboard_url() - assert url is not None - if SerenaDashboardViewer.is_current_platform_supported(): - self._dashboard_viewer_process = multiprocessing.Process( - target=self._start_dashboard_viewer_process_function, args=(url, minimized, os.getpid()), daemon=True - ) - self._dashboard_viewer_process.start() - else: - log.info("Not starting Serena dashboard viewer because the current platform does not support it; using browser-based fallback") - if not minimized: - self._open_dashboard_in_browser(url) - - def _ensure_tray_manager_and_register(self, open_on_launch: bool) -> None: - """ - Ensure the tray manager is running and register this dashboard instance with it. - - If the current platform supports the native tray manager, this method starts - the manager (if not already running) and registers the instance. Otherwise, - it falls back to opening the dashboard in the default web browser. - - :param open_on_launch: whether the dashboard should be opened immediately - """ - if not system_has_usable_display(): - log.info("Not starting the Serena tray manager because no usable display was detected.") - return - - url = self.get_dashboard_url() - assert url is not None - assert self._dashboard_port is not None - - if SerenaDashboardTrayManager.is_current_platform_supported(): - # ensure the singleton tray manager process is running - SerenaDashboardTrayManager.ensure_running() - - # determine the current project name (if any) - project_name = self._active_project.project_name if self._active_project is not None else None - - # register this instance with the tray manager, optionally opening a viewer - SerenaDashboardTrayManager.register_instance( - port=self._dashboard_port, - dashboard_url=url, - project=project_name, - started_at=self._agent_started_at, - open_viewer=open_on_launch, - ) - else: - log.info("Not starting Serena tray manager because the current platform does not support it; using browser-based fallback") - if open_on_launch: - self._open_dashboard_in_browser(url) + if self._dashboard_manager is None: + return None + return self._dashboard_manager.url def open_dashboard(self) -> bool: """ @@ -661,27 +719,16 @@ class SerenaAgent: :return: True if the dashboard was opened, False if it could not be opened """ - if self._dashboard_url is None: + if self._dashboard_manager is None: raise Exception("Dashboard is not running.") if not system_has_usable_display(): log.warning("Not opening the Serena web dashboard because no usable display was detected.") return False - self._open_dashboard_in_browser(self._dashboard_url) + self._dashboard_manager.open_dashboard_in_browser() return True - @staticmethod - def _open_dashboard_in_browser(url: str) -> None: - # Use a subprocess to avoid any output from webbrowser.open being written to stdout - subprocess.Popen( - [sys.executable, "-c", f"import webbrowser; webbrowser.open({url!r})"], - stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - start_new_session=False, - ) - def get_exposed_tool_instances(self) -> list["Tool"]: """ :return: the tool instances which are exposed (e.g. to the MCP client). @@ -900,10 +947,9 @@ class SerenaAgent: if self._project_activation_callback is not None: self._project_activation_callback() - # notify the tray manager of the project change (if the dashboard is running) - dashboard_port = getattr(self, "_dashboard_port", None) - if dashboard_port is not None and SerenaDashboardTrayManager.is_current_platform_supported(): - SerenaDashboardTrayManager.update_project(dashboard_port, project.project_name) + # notify the dashboard manager of the project change (if applicable) + if self._dashboard_manager: + self._dashboard_manager.update_active_project(self._active_project) return True @@ -1048,13 +1094,9 @@ class SerenaAgent: log.info("Stopping the GUI log window ...") self._gui_log_viewer.stop() self._gui_log_viewer = None - if self._dashboard_viewer_process: - log.info("Stopping the dashboard viewer process ...") - self._dashboard_viewer_process.terminate() - self._dashboard_viewer_process = None - if hasattr(self, "_dashboard_port"): - if self._dashboard_port is not None and SerenaDashboardTrayManager.is_current_platform_supported(): - SerenaDashboardTrayManager.unregister_instance(self._dashboard_port) + if self._dashboard_manager: + self._dashboard_manager.shutdown() + self._dashboard_manager = None def shutdown(self) -> None: """ From f6da084ab2f113ae248be410046efecb8561a0ba Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 17 Apr 2026 23:19:49 +0200 Subject: [PATCH 05/34] SerenaDashboardTrayManager: Make pywebview mode optional (open browser as new default) --- src/serena/agent.py | 20 ++++------------ src/serena/dashboard.py | 53 ++++++++++++++++++++++++++++++----------- 2 files changed, 43 insertions(+), 30 deletions(-) diff --git a/src/serena/agent.py b/src/serena/agent.py index 9b37a04b..55499985 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -7,8 +7,6 @@ import multiprocessing import os import platform import signal -import subprocess -import sys from collections.abc import Callable, Iterator, Sequence from contextlib import contextmanager from datetime import datetime @@ -35,7 +33,7 @@ from serena.config.serena_config import ( SerenaPaths, ToolInclusionDefinition, ) -from serena.dashboard import SerenaDashboardAPI, SerenaDashboardTrayManager, SerenaDashboardViewer +from serena.dashboard import SerenaDashboardAPI, SerenaDashboardTrayManager, SerenaDashboardViewer, open_url_in_browser from serena.ls_manager import LanguageServerManager from serena.project import MemoriesManager, Project from serena.prompt_factory import SerenaPromptFactory @@ -276,7 +274,8 @@ class DashboardManager: def __init__(self, port: int, host_listen_address: str, open_dashboard_on_launch: bool, active_project: Project | None = None): self._port = port - self._mode = self.Mode.from_platform() + # self._mode = self.Mode.from_platform() + self._mode = self.Mode.TRAY_MANAGER self._dashboard_viewer_process: multiprocessing.Process | None = None dashboard_host = host_listen_address @@ -297,19 +296,8 @@ class DashboardManager: else: self.open_dashboard_in_browser() - @staticmethod - def _open_url_in_browser(url: str) -> None: - # Use a subprocess to avoid any output from webbrowser.open being written to stdout - subprocess.Popen( - [sys.executable, "-c", f"import webbrowser; webbrowser.open({url!r})"], - stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - start_new_session=False, - ) - def open_dashboard_in_browser(self) -> None: - return self._open_url_in_browser(self.url) + open_url_in_browser(self.url) @staticmethod def _start_dashboard_viewer_process_function(url: str, minimized: bool, parent_process_id: int) -> None: diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index 2c22d454..e3b198a5 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -1,4 +1,5 @@ import json +import multiprocessing import os import socket import subprocess @@ -13,6 +14,7 @@ from html import escape from pathlib import Path from typing import TYPE_CHECKING, Any, Self +import pystray from flask import Flask, Response, redirect, request, send_from_directory from PIL import Image from pydantic import BaseModel @@ -787,6 +789,22 @@ class SerenaDashboardAPI: return thread, port +def open_url_in_browser(url: str) -> None: + """ + Opens the given URL in the user's default web browser without blocking the main process or printing any output. + + :param url: the URL to open + """ + # Use a subprocess to avoid any output from webbrowser.open being written to stdout + subprocess.Popen( + [sys.executable, "-c", f"import webbrowser; webbrowser.open({url!r})"], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=False, + ) + + class SerenaDashboardViewer(WebViewWithTray): """ Minimal pywebview wrapper that opens a dashboard in a native window. @@ -887,12 +905,17 @@ class SerenaDashboardTrayManager: ALIVE_CHECK_INTERVAL_SECONDS = 15 """interval in seconds between alive checks of registered instances""" - def __init__(self) -> None: + def __init__(self, use_pywebview: bool = False) -> None: + """ + :param use_pywebview: whether to use pywebview-based viewer applications (separate child processes) + for opening dashboards; if False, open them directly in the user's default web browser. + """ self._instances: dict[int, TrayManagedInstance] = {} self._lock = threading.Lock() - self._tray_icon: Any = None + self._tray_icon: pystray.Icon | None = None self._app = Flask(__name__) self._setup_routes() + self._use_pywebview = use_pywebview def _setup_routes(self) -> None: @self._app.route("/health", methods=["GET"]) @@ -914,7 +937,7 @@ class SerenaDashboardTrayManager: # open a viewer immediately if requested if data.get("open_viewer", False): - self._open_viewer(instance) + self._open_dashboard(instance) return {"status": "registered"} @@ -976,7 +999,7 @@ class SerenaDashboardTrayManager: # closure to capture the current instance def _make_callback(instance: TrayManagedInstance) -> Callable: def _callback(_icon: Any, _item: Any) -> None: - self._open_viewer(instance) + self._open_dashboard(instance) return _callback @@ -984,16 +1007,18 @@ class SerenaDashboardTrayManager: return tuple(items) - def _open_viewer(self, instance: TrayManagedInstance) -> None: - """Spawn a dashboard viewer window for the given instance in a new process.""" - import multiprocessing - - process = multiprocessing.Process( - target=self._run_viewer, - args=(instance.dashboard_url,), - daemon=True, - ) - process.start() + def _open_dashboard(self, instance: TrayManagedInstance) -> None: + """Opens the dashboard of the given instance.""" + if self._use_pywebview: + # spawn pywebview process showing the dashboard + process = multiprocessing.Process( + target=self._run_viewer, + args=(instance.dashboard_url,), + daemon=True, + ) + process.start() + else: + open_url_in_browser(instance.dashboard_url) @staticmethod def _run_viewer(url: str) -> None: From 1b895e7d90718955e7b9c829e7511239d02058ed Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 17 Apr 2026 23:17:38 +0200 Subject: [PATCH 06/34] Ensure tray manager context menu is up-to-date after every change --- src/serena/dashboard.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index e3b198a5..76528e71 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -12,9 +12,8 @@ from collections.abc import Callable from dataclasses import dataclass from html import escape from pathlib import Path -from typing import TYPE_CHECKING, Any, Self +from typing import TYPE_CHECKING, Any, Optional, Self -import pystray from flask import Flask, Response, redirect, request, send_from_directory from PIL import Image from pydantic import BaseModel @@ -910,9 +909,11 @@ class SerenaDashboardTrayManager: :param use_pywebview: whether to use pywebview-based viewer applications (separate child processes) for opening dashboards; if False, open them directly in the user's default web browser. """ + import pystray + self._instances: dict[int, TrayManagedInstance] = {} self._lock = threading.Lock() - self._tray_icon: pystray.Icon | None = None + self._tray_icon: Optional["pystray.Icon"] = None self._app = Flask(__name__) self._setup_routes() self._use_pywebview = use_pywebview @@ -931,9 +932,10 @@ class SerenaDashboardTrayManager: project=data.get("project"), started_at=data["started_at"], ) + log.info("Registering instance on port %d (project=%s)", instance.port, instance.project) with self._lock: self._instances[instance.port] = instance - log.info("Registered instance on port %d (project=%s)", instance.port, instance.project) + self._update_menu() # open a viewer immediately if requested if data.get("open_viewer", False): @@ -949,6 +951,7 @@ class SerenaDashboardTrayManager: with self._lock: if port in self._instances: self._instances[port].project = project + self._update_menu() log.info("Updated project for instance on port %d to '%s'", port, project) return {"status": "updated"} @@ -958,9 +961,14 @@ class SerenaDashboardTrayManager: port = data["port"] with self._lock: self._instances.pop(port, None) + self._update_menu() log.info("Unregistered instance on port %d", port) return {"status": "unregistered"} + def _update_menu(self) -> None: + if self._tray_icon: + self._tray_icon.update_menu() + def _build_menu_items(self) -> tuple[Any, ...]: """ Callable that returns the current tray menu items. From dd734f84786b1460758b5790be7330d7228c4138 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 17 Apr 2026 23:57:22 +0200 Subject: [PATCH 07/34] Add central logging config in serena.__init__ --- src/serena/__init__.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/serena/__init__.py b/src/serena/__init__.py index 77077e5c..ce2b1abf 100644 --- a/src/serena/__init__.py +++ b/src/serena/__init__.py @@ -21,3 +21,15 @@ def serena_version() -> str: except: pass return version + + +def _init_log_configuration() -> None: + from sensai.util import logging + + def _configure() -> None: + logging.getLogger("PIL").setLevel(logging.WARNING) + + logging.set_configure_callback(_configure) + + +_init_log_configuration() From ce10a7bdea24d6286762ae2e1b896d7660879815 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 17 Apr 2026 23:57:37 +0200 Subject: [PATCH 08/34] SerenaDashboardTrayManager: Add debug mode with file logging --- src/serena/dashboard.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index 76528e71..890d9588 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -861,7 +861,7 @@ class SerenaDashboardViewer(WebViewWithTray): def run(self) -> None: if self.DEBUG: - logging.configure(level=logging.DEBUG) + logging.configure(level=logging.DEBUG, stream=sys.stderr) logging.add_file_logger(SerenaPaths().get_next_log_file_path("dashboard-viewer")) super().run() @@ -896,6 +896,8 @@ class SerenaDashboardTrayManager: needs it and terminates automatically when no dashboard instances remain. """ + DEBUG = False + PORT = SerenaPorts.TRAY_MANAGER_PORT HOST = "127.0.0.1" @@ -1075,6 +1077,12 @@ class SerenaDashboardTrayManager: """Run the tray manager (blocking). Starts Flask, alive-check thread, and tray icon.""" import pystray + if self.DEBUG: + logging.configure(level=logging.DEBUG, stream=sys.stderr) + logging.add_file_logger(SerenaPaths().get_next_log_file_path("tray-manager")) + + log.info("Starting tray manager") + dashboard_path = Path(SERENA_DASHBOARD_DIR) # select the appropriate icon for the platform From d50858593c72e3ace9f1821bda6af33ebf936975 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 18 Apr 2026 01:28:39 +0200 Subject: [PATCH 09/34] Initialise dashboard tray manager in a separate thread to avoid delays --- src/serena/agent.py | 44 ++++++++++++++++++++++++-------------------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/src/serena/agent.py b/src/serena/agent.py index 55499985..efec89eb 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -7,6 +7,7 @@ import multiprocessing import os import platform import signal +import threading from collections.abc import Callable, Iterator, Sequence from contextlib import contextmanager from datetime import datetime @@ -277,6 +278,7 @@ class DashboardManager: # self._mode = self.Mode.from_platform() self._mode = self.Mode.TRAY_MANAGER self._dashboard_viewer_process: multiprocessing.Process | None = None + self._tray_manager_lock = threading.Lock() dashboard_host = host_listen_address if dashboard_host == "0.0.0.0": @@ -288,7 +290,8 @@ class DashboardManager: case self.Mode.PYWEBVIEW: self._start_dashboard_viewer(minimized=not open_dashboard_on_launch) case self.Mode.TRAY_MANAGER: - self._tray_manager_register(open_on_launch=open_dashboard_on_launch, active_project=active_project) + init_fn = lambda: self._tray_manager_register(open_on_launch=open_dashboard_on_launch, active_project=active_project) + threading.Thread(target=init_fn, name="init-DashboardTrayManager", daemon=True).start() case self.Mode.BROWSER: if open_dashboard_on_launch: if not system_has_usable_display(): @@ -335,24 +338,22 @@ class DashboardManager: :param open_on_launch: whether the dashboard should be opened immediately """ - if not system_has_usable_display(): - log.info("Not starting the Serena tray manager because no usable display was detected.") - return + with LogTime("Dashboard tray manager initialisation"): + with self._tray_manager_lock: + # ensure the singleton tray manager process is running + SerenaDashboardTrayManager.ensure_running() - # ensure the singleton tray manager process is running - SerenaDashboardTrayManager.ensure_running() + # determine the current project name (if any) + project_name = active_project.project_name if active_project is not None else None - # determine the current project name (if any) - project_name = active_project.project_name if active_project is not None else None - - # register this instance with the tray manager - SerenaDashboardTrayManager.register_instance( - port=self._port, - dashboard_url=self.url, - project=project_name, - started_at=datetime.now().isoformat(timespec="seconds"), - open_viewer=open_on_launch, - ) + # register this instance with the tray manager + SerenaDashboardTrayManager.register_instance( + port=self._port, + dashboard_url=self.url, + project=project_name, + started_at=datetime.now().isoformat(timespec="seconds"), + open_viewer=open_on_launch, + ) def shutdown(self) -> None: """ @@ -362,8 +363,10 @@ class DashboardManager: log.info("Stopping the dashboard viewer process ...") self._dashboard_viewer_process.terminate() self._dashboard_viewer_process = None + if self._mode == self.Mode.TRAY_MANAGER: - SerenaDashboardTrayManager.unregister_instance(port=self._port) + with self._tray_manager_lock: + SerenaDashboardTrayManager.unregister_instance(port=self._port) def update_active_project(self, active_project: Project | None) -> None: """ @@ -372,8 +375,9 @@ class DashboardManager: :param active_project: the currently active project or None if no project is active """ if self._mode == self.Mode.TRAY_MANAGER: - project_name = active_project.project_name if active_project is not None else None - SerenaDashboardTrayManager.update_project(port=self._port, project=project_name) + with self._tray_manager_lock: + project_name = active_project.project_name if active_project is not None else None + SerenaDashboardTrayManager.update_project(port=self._port, project=project_name) class SerenaAgent: From f69110eff07104f7aec1d78dd4931fe0c42a8b30 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 18 Apr 2026 01:44:25 +0200 Subject: [PATCH 10/34] SerenaDashboardTrayManager: Support use of process id for alive checks (new default) --- src/serena/dashboard.py | 86 ++++++++++++++++++++++++++++------------- 1 file changed, 59 insertions(+), 27 deletions(-) diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index 890d9588..f66a7b93 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -14,6 +14,7 @@ from html import escape from pathlib import Path from typing import TYPE_CHECKING, Any, Optional, Self +import psutil from flask import Flask, Response, redirect, request, send_from_directory from PIL import Image from pydantic import BaseModel @@ -874,6 +875,9 @@ class TrayManagedInstance: port: int """the port on which the dashboard API is listening""" + parent_process: psutil.Process + """the process of the Serena agent owning this dashboard instance""" + dashboard_url: str """the full URL to the dashboard frontend""" @@ -903,19 +907,24 @@ class SerenaDashboardTrayManager: HOST = "127.0.0.1" """listen address (local only)""" - ALIVE_CHECK_INTERVAL_SECONDS = 15 + ALIVE_CHECK_INTERVAL_SECONDS = 3 """interval in seconds between alive checks of registered instances""" - def __init__(self, use_pywebview: bool = False) -> None: + def __init__(self, use_pywebview: bool = False, alive_check_use_pid: bool = True) -> None: """ :param use_pywebview: whether to use pywebview-based viewer applications (separate child processes) for opening dashboards; if False, open them directly in the user's default web browser. + :param alive_check_use_pid: whether to use the process ID for alive checks of registered instances. + If True, the manager will check whether the process with the registered PID is still running. + If False, the manager will perform an HTTP request to the instance's heartbeat endpoint to check + if it's alive. """ import pystray self._instances: dict[int, TrayManagedInstance] = {} self._lock = threading.Lock() self._tray_icon: Optional["pystray.Icon"] = None + self._alive_check_use_pid = alive_check_use_pid self._app = Flask(__name__) self._setup_routes() self._use_pywebview = use_pywebview @@ -930,6 +939,7 @@ class SerenaDashboardTrayManager: data = request.get_json() instance = TrayManagedInstance( port=data["port"], + parent_process=psutil.Process(data["pid"]), dashboard_url=data["dashboard_url"], project=data.get("project"), started_at=data["started_at"], @@ -1043,35 +1053,56 @@ class SerenaDashboardTrayManager: while True: time.sleep(self.ALIVE_CHECK_INTERVAL_SECONDS) - # collect ports to check - with self._lock: - ports_to_check = list(self._instances.keys()) + try: + dead_ports: list[int] = [] - # probe each instance - dead_ports: list[int] = [] - for port in ports_to_check: - try: - url = f"http://127.0.0.1:{port}/heartbeat" - req = urllib.request.Request(url, method="GET") - urllib.request.urlopen(req, timeout=3) - except Exception: - dead_ports.append(port) + if self._alive_check_use_pid: + with self._lock: + ports_and_processes = [(port, inst.parent_process) for port, inst in self._instances.items()] - # remove dead instances - if dead_ports: + dead_ports = [] + for port, process in ports_and_processes: + is_dead = False + try: + if not process.is_running(): + is_dead = True + except psutil.NoSuchProcess: + # parent process already exited + is_dead = True + if is_dead: + dead_ports.append(port) + else: + # collect ports to check + with self._lock: + ports_to_check = list(self._instances.keys()) + + # probe each instance + for port in ports_to_check: + try: + url = f"http://127.0.0.1:{port}/heartbeat" + req = urllib.request.Request(url, method="GET") + urllib.request.urlopen(req, timeout=1) + except Exception: + dead_ports.append(port) + + # remove dead instances + if dead_ports: + with self._lock: + for port in dead_ports: + self._instances.pop(port, None) + log.info("Removed unreachable instance on port %d", port) + + # terminate if no instances remain with self._lock: - for port in dead_ports: - self._instances.pop(port, None) - log.info("Removed unreachable instance on port %d", port) + remaining = len(self._instances) + if remaining == 0: + log.info("No dashboard instances remaining; shutting down tray manager") + if self._tray_icon is not None: + self._tray_icon.stop() + return - # terminate if no instances remain - with self._lock: - remaining = len(self._instances) - if remaining == 0: - log.info("No dashboard instances remaining; shutting down tray manager") - if self._tray_icon is not None: - self._tray_icon.stop() - return + except Exception as e: + log.error("Error during alive check loop: %s", e, exc_info=e) def run(self) -> None: """Run the tray manager (blocking). Starts Flask, alive-check thread, and tray icon.""" @@ -1197,6 +1228,7 @@ class SerenaDashboardTrayManager: data = json.dumps( { "port": port, + "pid": os.getpid(), "dashboard_url": dashboard_url, "project": project, "started_at": started_at, From d87b316c519a17015e54b3a196985f859c6f5c4f Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sun, 19 Apr 2026 14:54:31 +0200 Subject: [PATCH 11/34] Make dashboard interface mode configurable --- src/serena/agent.py | 58 ++++++++++++++++--- src/serena/config/serena_config.py | 1 + .../resources/serena_config.template.yml | 13 +++++ 3 files changed, 64 insertions(+), 8 deletions(-) diff --git a/src/serena/agent.py b/src/serena/agent.py index efec89eb..c37d3b04 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -252,10 +252,11 @@ class DashboardManager: """ Open the dashboard in the default browser; supported on all platforms. """ - PYWEBVIEW = "webview" + WEBVIEW = "app" """ - Open the dashboard via a native window (using pywebview) which minimises to the tray; supported on Windows only - (on macOS, tray apps for multiple instances accumulate in the top bar, which users do not want) + Open the dashboard via a native window (using pywebview) which minimises to the tray; + supported on Windows and macOS (but on macOS, tray apps for multiple instances accumulate + in the top bar, which users may not want) """ TRAY_MANAGER = "tray_manager" """ @@ -267,15 +268,52 @@ class DashboardManager: def from_platform(cls) -> "DashboardManager.Mode": match platform.system(): case "Windows": - return cls.PYWEBVIEW + return cls.WEBVIEW case "Darwin": return cls.TRAY_MANAGER case _: return cls.BROWSER - def __init__(self, port: int, host_listen_address: str, open_dashboard_on_launch: bool, active_project: Project | None = None): + def is_supported(self) -> bool: + """ + :return: whether the mode is supported on the current platform + """ + if self == DashboardManager.Mode.WEBVIEW: + return SerenaDashboardViewer.is_current_platform_supported() + elif self == DashboardManager.Mode.TRAY_MANAGER: + return SerenaDashboardTrayManager.is_current_platform_supported() + else: + return True + + def __init__( + self, + port: int, + host_listen_address: str, + open_dashboard_on_launch: bool, + active_project: Project | None = None, + mode_str: str | None = None, + ): + # determine requested mode + if mode_str is not None: + try: + mode = self.Mode(mode_str) + except ValueError: + mode = self.Mode.from_platform() + log.warning(f"Invalid dashboard interface mode '{mode_str}' specified; falling back to platform default '{mode.value}'.") + else: + mode = self.Mode.from_platform() + + # check for mode compatibility + if not mode.is_supported(): + fallback_mode = self.Mode.from_platform() + log.warning( + f"Dashboard interface mode '{mode.value}' is not supported on the current platform; " + "falling back to '{fallback_mode.value}'." + ) + mode = fallback_mode + self._port = port - # self._mode = self.Mode.from_platform() + self._mode = mode self._mode = self.Mode.TRAY_MANAGER self._dashboard_viewer_process: multiprocessing.Process | None = None self._tray_manager_lock = threading.Lock() @@ -287,7 +325,7 @@ class DashboardManager: # handle startup match self._mode: - case self.Mode.PYWEBVIEW: + case self.Mode.WEBVIEW: self._start_dashboard_viewer(minimized=not open_dashboard_on_launch) case self.Mode.TRAY_MANAGER: init_fn = lambda: self._tray_manager_register(open_on_launch=open_dashboard_on_launch, active_project=active_project) @@ -529,7 +567,11 @@ class SerenaAgent: get_memory_log_handler(), tool_names, agent=self, tool_usage_stats=self._tool_usage_stats ).run_in_thread(host=self.serena_config.web_dashboard_listen_address) self._dashboard_manager = DashboardManager( - port, self.serena_config.web_dashboard_listen_address, self.serena_config.web_dashboard_open_on_launch, self._active_project + port, + self.serena_config.web_dashboard_listen_address, + self.serena_config.web_dashboard_open_on_launch, + self._active_project, + mode_str=self.serena_config.web_dashboard_interface, ) log.info("Serena web dashboard started at %s", self._dashboard_manager.url) # inform the GUI window (if any) diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 26815061..2f787f6d 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -682,6 +682,7 @@ class SerenaConfig(SharedConfig): trace_lsp_communication: bool = False web_dashboard: bool = True web_dashboard_open_on_launch: bool = True + web_dashboard_interface: str | None = None web_dashboard_listen_address: str = "127.0.0.1" jetbrains_plugin_server_address: str = "127.0.0.1" tool_timeout: float = DEFAULT_TOOL_TIMEOUT diff --git a/src/serena/resources/serena_config.template.yml b/src/serena/resources/serena_config.template.yml index a823c743..5eb7ca3b 100644 --- a/src/serena/resources/serena_config.template.yml +++ b/src/serena/resources/serena_config.template.yml @@ -46,6 +46,19 @@ web_dashboard: True # See also: https://oraios.github.io/serena/02-usage/060_dashboard.html web_dashboard_open_on_launch: True +# defines the interface (application mode) used for the web dashboard (if enabled). +# If empty/null, use platform-specific default. Otherwise, possible values: +# * browser: the dashboard is opened in the default browser (if `web_dashboard_open_on_launch` is true) +# This is supported on all platforms. +# * app: the dashboard is opened in a separate native-like app window with accompanying tray icon, whose +# lifecycle is tied to the Serena process. +# If `web_dashboard_open_on_launch` is false, the dashboard can be conveniently accessed via the tray icon. +# This is supported on Windows and macOS. +# * tray_manager: use a global tray icon to provide access to the dashboards of all running Serena instances, +# opening the dashboard in browser tabs when selected from the tray menu. +# This is supported on Windows and macOS. +web_dashboard_interface: + # the address the web dashboard will listen on (bind address). web_dashboard_listen_address: 127.0.0.1 From d6816caf588feb0db90c603938ac128ca0c1af3a Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Mon, 20 Apr 2026 13:48:22 +0200 Subject: [PATCH 12/34] Make open_url_in_brower more flexible, avoiding unnecessary subprocess in tray manager --- src/serena/agent.py | 4 ++-- src/serena/dashboard.py | 28 ++++++++++++++++++---------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/serena/agent.py b/src/serena/agent.py index c37d3b04..221b2cdf 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -338,7 +338,7 @@ class DashboardManager: self.open_dashboard_in_browser() def open_dashboard_in_browser(self) -> None: - open_url_in_browser(self.url) + open_url_in_browser(self.url, use_subprocess=True) @staticmethod def _start_dashboard_viewer_process_function(url: str, minimized: bool, parent_process_id: int) -> None: @@ -351,7 +351,7 @@ class DashboardManager: log.warning(f"Could not open Serena Dashboard viewer. Cause:\n{e}") # Fall back to opening the browser window if the window was supposed to be shown directly if not minimized: - open_url_in_browser(url) + open_url_in_browser(url, use_subprocess=True) def _start_dashboard_viewer(self, minimized: bool) -> None: """ diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index f66a7b93..e1da5efa 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -789,20 +789,28 @@ class SerenaDashboardAPI: return thread, port -def open_url_in_browser(url: str) -> None: +def open_url_in_browser(url: str, use_subprocess: bool = False) -> None: """ - Opens the given URL in the user's default web browser without blocking the main process or printing any output. + Opens the given URL in the user's default web browser, + optionally using a subprocess to ensure that no output is written to stdout + (highly problematic when run within a stdio MCP server context) :param url: the URL to open + :param use_subprocess: whether to use a subprocess to opening the URL, making stdio contamination impossible """ - # Use a subprocess to avoid any output from webbrowser.open being written to stdout - subprocess.Popen( - [sys.executable, "-c", f"import webbrowser; webbrowser.open({url!r})"], - stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - start_new_session=False, - ) + if use_subprocess: + # Use a subprocess to avoid any output from webbrowser.open being written to stdout + subprocess.Popen( + [sys.executable, "-c", f"import webbrowser; webbrowser.open({url!r})"], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=False, + ) + else: + import webbrowser + + webbrowser.open(url) class SerenaDashboardViewer(WebViewWithTray): From 8ebe6322f2feb0a73970226875c7726b50c75533 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 18:12:38 +0000 Subject: [PATCH 13/34] Bump nbconvert from 7.17.0 to 7.17.1 in the uv group across 1 directory Bumps the uv group with 1 update in the / directory: [nbconvert](https://github.com/jupyter/nbconvert). Updates `nbconvert` from 7.17.0 to 7.17.1 - [Release notes](https://github.com/jupyter/nbconvert/releases) - [Changelog](https://github.com/jupyter/nbconvert/blob/main/CHANGELOG.md) - [Commits](https://github.com/jupyter/nbconvert/compare/v7.17.0...v7.17.1) --- updated-dependencies: - dependency-name: nbconvert dependency-version: 7.17.1 dependency-type: direct:production dependency-group: uv ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e09bf469..b614c0dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -94,7 +94,7 @@ dev = [ # Transitive dev deps pinned for security (dependabot alerts). # Exact pins because uvx installs from git, ignoring the lock file. "tornado==6.5.5", - "nbconvert==7.17.0", + "nbconvert==7.17.1", "wheel==0.46.3", "pyasn1==0.6.3", "PyJWT==2.12.0", diff --git a/uv.lock b/uv.lock index 793e80cb..45d57917 100644 --- a/uv.lock +++ b/uv.lock @@ -1638,7 +1638,7 @@ wheels = [ [[package]] name = "nbconvert" -version = "7.17.0" +version = "7.17.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "beautifulsoup4" }, @@ -1656,9 +1656,9 @@ dependencies = [ { name = "pygments" }, { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/38/47/81f886b699450d0569f7bc551df2b1673d18df7ff25cc0c21ca36ed8a5ff/nbconvert-7.17.0.tar.gz", hash = "sha256:1b2696f1b5be12309f6c7d707c24af604b87dfaf6d950794c7b07acab96dda78", size = 862855, upload-time = "2026-01-29T16:37:48.478Z" } +sdist = { url = "https://files.pythonhosted.org/packages/01/b1/708e53fe2e429c103c6e6e159106bcf0357ac41aa4c28772bd8402339051/nbconvert-7.17.1.tar.gz", hash = "sha256:34d0d0a7e73ce3cbab6c5aae8f4f468797280b01fd8bd2ca746da8569eddd7d2", size = 865311, upload-time = "2026-04-08T00:44:14.914Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/4b/8d5f796a792f8a25f6925a96032f098789f448571eb92011df1ae59e8ea8/nbconvert-7.17.0-py3-none-any.whl", hash = "sha256:4f99a63b337b9a23504347afdab24a11faa7d86b405e5c8f9881cd313336d518", size = 261510, upload-time = "2026-01-29T16:37:46.322Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/bb0a9d5f46819c821dc1f004aa2cc29b1d91453297dbf5ff20470f00f193/nbconvert-7.17.1-py3-none-any.whl", hash = "sha256:aa85c087b435e7bf1ffd03319f658e285f2b89eccab33bc1ba7025495ab3e7c8", size = 261927, upload-time = "2026-04-08T00:44:12.845Z" }, ] [[package]] @@ -3043,7 +3043,7 @@ requires-dist = [ { name = "lsprotocol", specifier = "==2025.0.0" }, { name = "mcp", specifier = "==1.26.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = "==1.17.0" }, - { name = "nbconvert", marker = "extra == 'dev'", specifier = "==7.17.0" }, + { name = "nbconvert", marker = "extra == 'dev'", specifier = "==7.17.1" }, { name = "nbsphinx", marker = "extra == 'dev'", specifier = "==0.9.7" }, { name = "overrides", specifier = "==7.7.0" }, { name = "pathspec", specifier = "==0.12.1" }, From 91977532fd3fb0d805b63ae6034003d086f6450e Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Wed, 22 Apr 2026 00:48:56 +0200 Subject: [PATCH 14/34] Remove obsolete notebook-related dev dependencies (nbsphinx, nbconvert) --- pyproject.toml | 2 - uv.lock | 111 ------------------------------------------------- 2 files changed, 113 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b614c0dc..df90518d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -88,13 +88,11 @@ dev = [ "sphinx_book_theme==1.1.4", "sphinx-toolbox==3.7.0", "jupyter-book==1.0.4.post1", - "nbsphinx==0.9.7", "pyinstrument==5.1.1", "pytest-timeout==2.4.0", # Transitive dev deps pinned for security (dependabot alerts). # Exact pins because uvx installs from git, ignoring the lock file. "tornado==6.5.5", - "nbconvert==7.17.1", "wheel==0.46.3", "pyasn1==0.6.3", "PyJWT==2.12.0", diff --git a/uv.lock b/uv.lock index 45d57917..f5f91eff 100644 --- a/uv.lock +++ b/uv.lock @@ -190,23 +190,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/fe/3aed5d0be4d404d12d36ab97e2f1791424d9ca39c2f754a6285d59a3b01d/beautifulsoup4-4.14.2-py3-none-any.whl", hash = "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515", size = 106392, upload-time = "2025-09-29T10:05:43.771Z" }, ] -[[package]] -name = "bleach" -version = "6.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "webencodings" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/07/18/3c8523962314be6bf4c8989c79ad9531c825210dd13a8669f6b84336e8bd/bleach-6.3.0.tar.gz", hash = "sha256:6f3b91b1c0a02bb9a78b5a454c92506aa0fdf197e1d5e114d2e00c6f64306d22", size = 203533, upload-time = "2025-10-27T17:57:39.211Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cd/3a/577b549de0cc09d95f11087ee63c739bba856cd3952697eec4c4bb91350a/bleach-6.3.0-py3-none-any.whl", hash = "sha256:fe10ec77c93ddf3d13a73b035abaac7a9f5e436513864ccdad516693213c65d6", size = 164437, upload-time = "2025-10-27T17:57:37.538Z" }, -] - -[package.optional-dependencies] -css = [ - { name = "tinycss2" }, -] - [[package]] name = "blinker" version = "1.9.0" @@ -571,15 +554,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, ] -[[package]] -name = "defusedxml" -version = "0.7.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, -] - [[package]] name = "dict2css" version = "0.3.0.post1" @@ -1274,15 +1248,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" }, ] -[[package]] -name = "jupyterlab-pygments" -version = "0.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/90/51/9187be60d989df97f5f0aba133fa54e7300f17616e065d1ada7d7646b6d6/jupyterlab_pygments-0.3.0.tar.gz", hash = "sha256:721aca4d9029252b11cfa9d185e5b5af4d54772bb8072f9b7036f4170054d35d", size = 512900, upload-time = "2023-11-23T09:26:37.44Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/dd/ead9d8ea85bf202d90cc513b533f9c363121c7792674f78e0d8a854b63b4/jupyterlab_pygments-0.3.0-py3-none-any.whl", hash = "sha256:841a89020971da1d8693f1a99997aefc5dc424bb1b251fd6322462a1b8842780", size = 15884, upload-time = "2023-11-23T09:26:34.325Z" }, -] - [[package]] name = "latexcodec" version = "3.0.1" @@ -1461,15 +1426,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] -[[package]] -name = "mistune" -version = "3.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9d/55/d01f0c4b45ade6536c51170b9043db8b2ec6ddf4a35c7ea3f5f559ac935b/mistune-3.2.0.tar.gz", hash = "sha256:708487c8a8cdd99c9d90eb3ed4c3ed961246ff78ac82f03418f5183ab70e398a", size = 95467, upload-time = "2025-12-23T11:36:34.994Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/f7/4a5e785ec9fbd65146a27b6b70b6cdc161a66f2024e4b04ac06a67f5578b/mistune-3.2.0-py3-none-any.whl", hash = "sha256:febdc629a3c78616b94393c6580551e0e34cc289987ec6c35ed3f4be42d0eee1", size = 53598, upload-time = "2025-12-23T11:36:33.211Z" }, -] - [[package]] name = "more-itertools" version = "10.8.0" @@ -1636,31 +1592,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/83/a0/5b0c2f11142ed1dddec842457d3f65eaf71a0080894eb6f018755b319c3a/nbclient-0.10.4-py3-none-any.whl", hash = "sha256:9162df5a7373d70d606527300a95a975a47c137776cd942e52d9c7e29ff83440", size = 25465, upload-time = "2025-12-23T07:45:44.51Z" }, ] -[[package]] -name = "nbconvert" -version = "7.17.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "beautifulsoup4" }, - { name = "bleach", extra = ["css"] }, - { name = "defusedxml" }, - { name = "jinja2" }, - { name = "jupyter-core" }, - { name = "jupyterlab-pygments" }, - { name = "markupsafe" }, - { name = "mistune" }, - { name = "nbclient" }, - { name = "nbformat" }, - { name = "packaging" }, - { name = "pandocfilters" }, - { name = "pygments" }, - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/01/b1/708e53fe2e429c103c6e6e159106bcf0357ac41aa4c28772bd8402339051/nbconvert-7.17.1.tar.gz", hash = "sha256:34d0d0a7e73ce3cbab6c5aae8f4f468797280b01fd8bd2ca746da8569eddd7d2", size = 865311, upload-time = "2026-04-08T00:44:14.914Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/67/f8/bb0a9d5f46819c821dc1f004aa2cc29b1d91453297dbf5ff20470f00f193/nbconvert-7.17.1-py3-none-any.whl", hash = "sha256:aa85c087b435e7bf1ffd03319f658e285f2b89eccab33bc1ba7025495ab3e7c8", size = 261927, upload-time = "2026-04-08T00:44:12.845Z" }, -] - [[package]] name = "nbformat" version = "5.10.4" @@ -1676,23 +1607,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a9/82/0340caa499416c78e5d8f5f05947ae4bc3cba53c9f038ab6e9ed964e22f1/nbformat-5.10.4-py3-none-any.whl", hash = "sha256:3b48d6c8fbca4b299bf3982ea7db1af21580e4fec269ad087b9e81588891200b", size = 78454, upload-time = "2024-04-04T11:20:34.895Z" }, ] -[[package]] -name = "nbsphinx" -version = "0.9.7" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "docutils" }, - { name = "jinja2" }, - { name = "nbconvert" }, - { name = "nbformat" }, - { name = "sphinx" }, - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1e/84/b1856b7651ac34e965aa567a158714c7f3bd42a1b1ce76bf423ffb99872c/nbsphinx-0.9.7.tar.gz", hash = "sha256:abd298a686d55fa894ef697c51d44f24e53aa312dadae38e82920f250a5456fe", size = 180479, upload-time = "2025-03-03T19:46:08.069Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/49/2d/8c8e635bcc6757573d311bb3c5445426382f280da32b8cd6d82d501ef4a4/nbsphinx-0.9.7-py3-none-any.whl", hash = "sha256:7292c3767fea29e405c60743eee5393682a83982ab202ff98f5eb2db02629da8", size = 31660, upload-time = "2025-03-03T19:46:06.581Z" }, -] - [[package]] name = "nest-asyncio" version = "1.6.0" @@ -1729,15 +1643,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] -[[package]] -name = "pandocfilters" -version = "1.5.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/70/6f/3dd4940bbe001c06a65f88e36bad298bc7a0de5036115639926b0c5c0458/pandocfilters-1.5.1.tar.gz", hash = "sha256:002b4a555ee4ebc03f8b66307e287fa492e4a77b4ea14d3f934328297bb4939e", size = 8454, upload-time = "2024-01-18T20:08:13.726Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/af/4fbc8cab944db5d21b7e2a5b8e9211a03a79852b1157e2c102fcc61ac440/pandocfilters-1.5.1-py2.py3-none-any.whl", hash = "sha256:93be382804a9cdb0a7267585f157e5d1731bbe5545a85b268d6f5fe6232de2bc", size = 8663, upload-time = "2024-01-18T20:08:11.28Z" }, -] - [[package]] name = "parso" version = "0.8.6" @@ -2999,8 +2904,6 @@ dev = [ { name = "jinja2" }, { name = "jupyter-book" }, { name = "mypy" }, - { name = "nbconvert" }, - { name = "nbsphinx" }, { name = "poethepoet" }, { name = "pyasn1" }, { name = "pyinstrument" }, @@ -3043,8 +2946,6 @@ requires-dist = [ { name = "lsprotocol", specifier = "==2025.0.0" }, { name = "mcp", specifier = "==1.26.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = "==1.17.0" }, - { name = "nbconvert", marker = "extra == 'dev'", specifier = "==7.17.1" }, - { name = "nbsphinx", marker = "extra == 'dev'", specifier = "==0.9.7" }, { name = "overrides", specifier = "==7.7.0" }, { name = "pathspec", specifier = "==0.12.1" }, { name = "poethepoet", marker = "extra == 'dev'", specifier = "==0.36.0" }, @@ -3647,18 +3548,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/df/c7891ef9d2712ad774777271d39fdef63941ffba0a9d59b7ad1fd2765e57/tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71", size = 920667, upload-time = "2025-10-06T20:22:34.444Z" }, ] -[[package]] -name = "tinycss2" -version = "1.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "webencodings" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7a/fd/7a5ee21fd08ff70d3d33a5781c255cbe779659bd03278feb98b19ee550f4/tinycss2-1.4.0.tar.gz", hash = "sha256:10c0972f6fc0fbee87c3edb76549357415e94548c1ae10ebccdea16fb404a9b7", size = 87085, upload-time = "2024-10-24T14:58:29.895Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/34/ebdc18bae6aa14fbee1a08b63c015c72b64868ff7dae68808ab500c492e2/tinycss2-1.4.0-py3-none-any.whl", hash = "sha256:3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289", size = 26610, upload-time = "2024-10-24T14:58:28.029Z" }, -] - [[package]] name = "toml-sort" version = "0.24.2" From 7caff5f0320ae4c241a101933d583d50510ab043 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Wed, 22 Apr 2026 23:52:07 +0200 Subject: [PATCH 15/34] Adjust claude-code context * Keep the replace_content tool enabled * Instruct the agent to use Serena's editing tools, especially when editing code that was read through Serena This is in reaction to Claude Code's editing tool denying edits without corresponding prior reads Resolves #1400 --- src/serena/resources/config/contexts/claude-code.yml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/serena/resources/config/contexts/claude-code.yml b/src/serena/resources/config/contexts/claude-code.yml index b6d632a7..fe63878b 100644 --- a/src/serena/resources/config/contexts/claude-code.yml +++ b/src/serena/resources/config/contexts/claude-code.yml @@ -7,16 +7,15 @@ prompt: | **IMPORTANT**: If you haven't seen Serena's tools yet, you should use the tool search tool to load all of them right now. For exploring and reading code in a token-efficient manner, use Serena's code-centric overview and symbolic search tools. - For edits, where it makes sense, use Serena's editing tools since they are more token efficient. - Use your own editing tools only if Serena's editing tools would be suboptimal. - **IMPORTANT**: Never use a tool before reading its schema via the tool-search tool! + For edits, prefer Serena's editing tools since they are more token efficient. + Especially when editing code you have read through Serena, ALWAYS use Serena's tools for editing, because your built-in tools will deny such edits (they will assume you haven't read the content). + **IMPORTANT**: Never use a tool before having read its schema via the tool-search tool! excluded_tools: - create_text_file - read_file - execute_shell_command - - replace_content - find_file - list_dir - search_for_pattern From 26e989dddd4a56932d2e00d7c1b45bb1e3a8526e Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Wed, 22 Apr 2026 23:56:40 +0200 Subject: [PATCH 16/34] Reduce duplicate page init logic --- src/serena/resources/dashboard/dashboard.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/serena/resources/dashboard/dashboard.js b/src/serena/resources/dashboard/dashboard.js index df53868e..b41d22ff 100644 --- a/src/serena/resources/dashboard/dashboard.js +++ b/src/serena/resources/dashboard/dashboard.js @@ -485,11 +485,9 @@ class Dashboard { // Initialize the application this.loadToolNames().then(function () { - // Start on overview page self.loadNews(); - self.loadConfigOverview(); - self.startConfigPolling(); - self.startExecutionsPolling(); + // start on overview page + self.navigateToPage("overview"); }); // Initialize heartbeat interval setInterval(this.heartbeat.bind(this), 250); From a4913e67601d38e248bf2fd6563c06261033c42a Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 23 Apr 2026 00:14:40 +0200 Subject: [PATCH 17/34] Fix memory leaks in dashboard frontend when using Chromium-based browsers (including web view on Windows) * Remove heartbeat (browsers no longer allow windows to be closed anyway) * Prevent parallel requests pertaining to the executions queue Both measures eliminate parallel requests (which, apparently, can lead to memory leaks in Chromium-based browsers; no such observations in Firefox). Fixes #1389 --- CHANGELOG.md | 2 + src/serena/resources/dashboard/dashboard.js | 69 +++++++++------------ 2 files changed, 31 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 836f2a23..1a4840b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,8 @@ Status of the `main` branch. Changes prior to the next official version change w - Fix: `rename_symbol` for Vue files now correctly propagates edits to the TypeScript server, enabling cross-file renames in `.vue` files - Fix: Lean4 stale cache — empty document symbol responses (returned before `lake build` completes) are no longer persisted, preventing symbols from being permanently hidden #1356 +Dashboard: + - Fix: Memory leaks in frontend when using Chromium-based browsers/Windows webview #1389 # v1.1.2 (2026-04-14) diff --git a/src/serena/resources/dashboard/dashboard.js b/src/serena/resources/dashboard/dashboard.js index b41d22ff..e48ff3c2 100644 --- a/src/serena/resources/dashboard/dashboard.js +++ b/src/serena/resources/dashboard/dashboard.js @@ -489,27 +489,6 @@ class Dashboard { // start on overview page self.navigateToPage("overview"); }); - // Initialize heartbeat interval - setInterval(this.heartbeat.bind(this), 250); - } - - heartbeat() { - let self = this; - $.ajax({ - url: '/heartbeat', - type: 'GET', - success: function (response) { - self.heartbeatFailureCount = 0; - }, - error: function (xhr, status, error) { - self.heartbeatFailureCount++; - console.error('Heartbeat failure; count = ', self.heartbeatFailureCount); - if (self.heartbeatFailureCount >= 1) { - console.log('Server appears to be down, closing tab'); - window.close(); - } - }, - }); } toggleMenu() { @@ -539,7 +518,6 @@ class Dashboard { // Start appropriate polling for the page if (page === 'overview') { this.loadNews(); - this.loadConfigOverview(); this.startConfigPolling(); this.startExecutionsPolling(); } else if (page === 'logs') { @@ -597,7 +575,8 @@ class Dashboard { } else { console.log('Config unchanged, skipping display update'); } - }, error: function (xhr, status, error) { + }, + error: function (xhr, status, error) { console.error('Error loading config overview:', error); self.$configDisplay.html('
Error loading configuration
'); self.$basicStatsDisplay.html('
Error loading stats
'); @@ -605,24 +584,23 @@ class Dashboard { self.$availableToolsDisplay.html('
Error loading tools
'); self.$availableModesDisplay.html('
Error loading modes
'); self.$availableContextsDisplay.html('
Error loading contexts
'); - }, complete: function () { + }, + complete: function () { self.waitingForConfigPollingResult = false; } }); } startConfigPolling() { + this.loadConfigOverview(); this.configPollInterval = setInterval(this.loadConfigOverview.bind(this), 1000); } startExecutionsPolling() { + this.loadExecutions() // Poll every 1 second for executions (independent of config polling) // This ensures stuck executions can still be cancelled even if config polling is blocked - this.loadExecutions() - this.executionsPollInterval = setInterval(() => { - this.loadQueuedExecutions(); - this.loadLastExecution(); - }, 1000); + this.executionsPollInterval = setInterval(this.loadExecutions.bind(this), 1000); } displayConfig(config) { @@ -903,26 +881,30 @@ class Dashboard { // ===== Executions Methods ===== - loadQueuedExecutions() { + loadQueuedExecutions(onComplete) { let self = this; $.ajax({ - url: '/queued_task_executions', type: 'GET', success: function (response) { + url: '/queued_task_executions', type: 'GET', + success: function (response) { if (response.status === 'success') { self.displayActiveExecutionsQueue(response.queued_executions || []); } else { console.error('Error loading executions:', response.message); } - }, error: function (xhr, status, error) { + }, + error: function (xhr, status, error) { console.error('Error loading executions:', error); self.$activeExecutionQueueDisplay.html('
Error loading executions
'); - } + }, + complete: onComplete }); } - loadLastExecution() { + loadLastExecution(onComplete) { let self = this; $.ajax({ - url: '/last_execution', type: 'GET', success: function (response) { + url: '/last_execution', type: 'GET', + success: function (response) { if (response.status === 'success') { if (response.last_execution !== null && response.last_execution.logged) { self.displayLastExecution(response.last_execution); @@ -930,21 +912,28 @@ class Dashboard { } else { console.error('Error loading last execution:', response.message); } - }, error: function (xhr, status, error) { + }, + error: function (xhr, status, error) { console.error('Error loading last execution:', error); self.$lastExecutionDisplay.html('
Error loading last execution
'); - } + }, + complete: onComplete }); } loadExecutions() { + const self = this; if (this.waitingForExecutionsPollingResult) { console.log('Still waiting for previous executions poll result, skipping this poll'); - } else { + } + else { this.waitingForExecutionsPollingResult = true; console.log('Polling for executions...'); - this.loadQueuedExecutions(); - this.loadLastExecution(); + this.loadQueuedExecutions(function() { + self.loadLastExecution(function() { + self.waitingForExecutionsPollingResult = false; + }); + }); } } From 1beb2523f7fb634ddbf2bd0ac8da176c681bbfaa Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 23 Apr 2026 01:48:44 +0200 Subject: [PATCH 18/34] Improve docstrings --- src/serena/dashboard.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index e1da5efa..d295d22a 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -815,10 +815,7 @@ def open_url_in_browser(url: str, use_subprocess: bool = False) -> None: class SerenaDashboardViewer(WebViewWithTray): """ - Minimal pywebview wrapper that opens a dashboard in a native window. - - The viewer is a plain window without a system tray icon. Tray management - is handled separately by :class:`SerenaDashboardTrayManager`. + Minimal pywebview wrapper that opens a dashboard in a native window with optional system tray. """ DEBUG = False @@ -833,6 +830,15 @@ class SerenaDashboardViewer(WebViewWithTray): parent_process_id: int | None = None, tray: bool = True, ): + """ + :param url: the URL to open + :param width: the width of the window + :param height: the height of the window + :param start_minimized: whether to start the window minimized (to the tray if tray is enabled) + :param parent_process_id: the process ID of the parent Serena agent process, which is monitored for termination, + automatically closing the dashboard when the parent process dies + :param tray: whether to use a system tray icon (which the app minimizes to when the window is closed) + """ dashboard_path = Path(SERENA_DASHBOARD_DIR) # .ico is Windows-only; macOS expects a PNG for the window/dock icon. From 071fec981c331af7e870335490370b29037983f0 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 23 Apr 2026 01:57:24 +0200 Subject: [PATCH 19/34] Mark 'tray_manager' dashboard mode as experimental, reverting back to 'browser' on macOS until it is tested --- src/serena/agent.py | 4 ++-- src/serena/dashboard.py | 6 ++++-- src/serena/resources/serena_config.template.yml | 8 +++++--- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/serena/agent.py b/src/serena/agent.py index 221b2cdf..efe4fc18 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -270,7 +270,8 @@ class DashboardManager: case "Windows": return cls.WEBVIEW case "Darwin": - return cls.TRAY_MANAGER + # TODO: Switch to TRAY_MANAGER once support is tested + return cls.BROWSER case _: return cls.BROWSER @@ -314,7 +315,6 @@ class DashboardManager: self._port = port self._mode = mode - self._mode = self.Mode.TRAY_MANAGER self._dashboard_viewer_process: multiprocessing.Process | None = None self._tray_manager_lock = threading.Lock() diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index d295d22a..3b4186f0 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -1172,8 +1172,10 @@ class SerenaDashboardTrayManager: """ :return: whether the current platform supports the tray manager """ - supported_platforms = ["win32", "darwin"] - return sys.platform in supported_platforms + # supported_platforms = ["win32", "darwin"] + # return sys.platform in supported_platforms + # TODO: Enable all platforms for the time being for users to try out (only Windows tested) + return True @classmethod def is_running(cls) -> bool: diff --git a/src/serena/resources/serena_config.template.yml b/src/serena/resources/serena_config.template.yml index 5eb7ca3b..18974358 100644 --- a/src/serena/resources/serena_config.template.yml +++ b/src/serena/resources/serena_config.template.yml @@ -47,16 +47,18 @@ web_dashboard: True web_dashboard_open_on_launch: True # defines the interface (application mode) used for the web dashboard (if enabled). -# If empty/null, use platform-specific default. Otherwise, possible values: +# If empty/null, use platform-dependent default. Otherwise, possible values: # * browser: the dashboard is opened in the default browser (if `web_dashboard_open_on_launch` is true) # This is supported on all platforms. # * app: the dashboard is opened in a separate native-like app window with accompanying tray icon, whose # lifecycle is tied to the Serena process. # If `web_dashboard_open_on_launch` is false, the dashboard can be conveniently accessed via the tray icon. -# This is supported on Windows and macOS. +# This is supported on Windows and macOS, but note that on macOS, where tray icons are very visible, +# this may result in too many icons being displayed when using multi-agent setups. # * tray_manager: use a global tray icon to provide access to the dashboards of all running Serena instances, # opening the dashboard in browser tabs when selected from the tray menu. -# This is supported on Windows and macOS. +# This is EXPERIMENTAL. It is tested on Windows only. We will establish macOS support, but it is yet untested. +# On Linux, this cannot be universally supported, but it may work in some desktop environments. web_dashboard_interface: # the address the web dashboard will listen on (bind address). From 81349514aba0d9f0868a2379db79cc40da5d8174 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 23 Apr 2026 02:13:21 +0200 Subject: [PATCH 20/34] Mention new dashboard interface options in change log & documentation --- CHANGELOG.md | 2 ++ docs/02-usage/060_dashboard.md | 15 ++++++++++----- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a4840b3..2b28c8bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,8 @@ Status of the `main` branch. Changes prior to the next official version change w - Fix: Lean4 stale cache — empty document symbol responses (returned before `lake build` completes) are no longer persisted, preventing symbols from being permanently hidden #1356 Dashboard: + - Add configurable dashboard interface mode (new global configuration setting `web_dashboard_interface`): + Three modes (browser, native app with tray, tray manager for aggregating multiple instances) are supported, depending on the OS - Fix: Memory leaks in frontend when using Chromium-based browsers/Windows webview #1389 # v1.1.2 (2026-04-14) diff --git a/docs/02-usage/060_dashboard.md b/docs/02-usage/060_dashboard.md index 8e828aec..ae11fd4d 100644 --- a/docs/02-usage/060_dashboard.md +++ b/docs/02-usage/060_dashboard.md @@ -19,10 +19,13 @@ Both can be configured in Serena's [configuration](050_configuration) file (`ser ## The Serena Dashboard -The dashboard is a web application, which is opened via a native application wrapper on supported platforms (e.g. Windows), -where it will appear in the system tray. **Click on the tray icon** to open the application window. +The dashboard is a web application, which is opened in one of three ways: -On other platforms, where native app support is difficult to provide (e.g. Linux), the dashboard will be opened in your default web browser instead. + * via a native application wrapper with accompanying tray icon (on supported platforms) + * via a tray manager that aggregates multiple instances (on supported platforms) + * via your default web browser (supported on all platforms) + +Configure your preferred interface via the setting `web_dashoard_interface` in [Serena's global configuration file](global-config) (see below). By default, the dashboard can be accessed at `http://localhost:24282/dashboard/index.html`, but a higher port may be used if the default port is unavailable/multiple instances are running. @@ -45,6 +48,7 @@ The dashboard provides ... You can configure settings for the dashboard in the [global configuration file](global-config). In particular, you can configure + * the interface through which the dashboard is accessed (see above) * whether the dashboard is enabled at all. We highly recommend keeping it enabled, as it provides valuable insights into Serena's operation and allows you to adjust configuration conveniently. * whether the dashboard window is opened automatically when Serena is started (see below). @@ -54,11 +58,12 @@ In particular, you can configure When Serena is started, the Dashboard window is opened by default in order to make users aware of its existence. -If you prefer not to have this happen (e.g., to avoid focus stealing), you can prevent it from opening +If you prefer not to have this happen, you can prevent it from opening by setting `web_dashboard_open_on_launch: False` in the [global configuration file](global-config) or by passing `--open-web-dashboard False` to the `start-mcp-server` CLI command. -On platforms supporting the tray icon, you can conveniently open the dashboard at any time by clicking on the tray icon, so automatic +On platforms supporting the tray icon (see also configuration option `web_dashboard_interface`), +you can conveniently open the dashboard at any time by clicking on the tray icon, so automatic opening is not a requirement to be able to access the dashboard on these platforms. On other platforms, you may still access it by From 3e24972c619dfd635e91ca57eff102031f844248 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 23 Apr 2026 02:20:58 +0200 Subject: [PATCH 21/34] SerenaDashboardTrayManager: Add initial delay to monitoring thread --- src/serena/dashboard.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index 3b4186f0..d2f5e1fa 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -1064,6 +1064,8 @@ class SerenaDashboardTrayManager: Removes unreachable instances and terminates the manager when none remain. """ + time.sleep(max(1, 5 - self.ALIVE_CHECK_INTERVAL_SECONDS)) # initial delay to allow instances to start and register + while True: time.sleep(self.ALIVE_CHECK_INTERVAL_SECONDS) From 885266ffb0f7a22b5f426e591c714bf4e7d5c0f9 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 23 Apr 2026 11:03:32 +0200 Subject: [PATCH 22/34] open_url_in_browser: Surround subprocess creation with try-catch Resolves #1363 --- src/serena/dashboard.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index d2f5e1fa..9a35806c 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -800,13 +800,18 @@ def open_url_in_browser(url: str, use_subprocess: bool = False) -> None: """ if use_subprocess: # Use a subprocess to avoid any output from webbrowser.open being written to stdout - subprocess.Popen( - [sys.executable, "-c", f"import webbrowser; webbrowser.open({url!r})"], - stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - start_new_session=False, - ) + try: + subprocess.Popen( + [sys.executable, "-c", f"import webbrowser; webbrowser.open({url!r})"], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=False, + ) + except Exception as e: + # Subprocess creation can fail in rare cases (e.g. on some Linux systems; possibly subprocess/glibc bug) + # See #1363 + log.error("Failed to open URL (%s) in subprocess; %s", url, e) else: import webbrowser From d79e2c2a0f1874d3f8e74d8c9706e99ade372a1b Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 23 Apr 2026 14:50:04 +0200 Subject: [PATCH 23/34] Fix: SearchForPatternTool returned 1-based line numbers (in contrast to all other tools) Cause: Implementation of text_utils.search_text Serena's tools use 0-based line numbers, and this information is now also centrally added to the instructions (manual). --- CHANGELOG.md | 1 + .../config/prompt_templates/system_prompt.yml | 4 +++- src/serena/tools/file_tools.py | 4 ++-- src/serena/util/text_utils.py | 17 +++++++---------- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b28c8bb..678be1e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ Status of the `main` branch. Changes prior to the next official version change w - Project server: - `query_project`: Support use of project root instead of project name #1388 - `list_queryable_projects`: Return both project names and project roots + - Fix: `search_for_pattern` tool returned 1-based line numbers (in contrast to all other tools); cause: implementation of `text_utils.search_text` * JetBrains: - `Move` and `SafeDelete` tools: transform empty string to None (counteracts client errors) diff --git a/src/serena/resources/config/prompt_templates/system_prompt.yml b/src/serena/resources/config/prompt_templates/system_prompt.yml index debefcd2..35fffeda 100644 --- a/src/serena/resources/config/prompt_templates/system_prompt.yml +++ b/src/serena/resources/config/prompt_templates/system_prompt.yml @@ -41,7 +41,9 @@ prompts: The following global (not project-specific) memories are available to you: {{ global_memories_list }} {%- endif -%} {%- endif %} - + + Line numbers returned by Serena's tools are 0-based! + The context and modes of operation are described below. These determine how to interact with your user and which kinds of interactions are expected of you. diff --git a/src/serena/tools/file_tools.py b/src/serena/tools/file_tools.py index 5f27ddf9..4e7268e0 100644 --- a/src/serena/tools/file_tools.py +++ b/src/serena/tools/file_tools.py @@ -347,7 +347,6 @@ class SearchForPatternTool(Tool): is used to further restrict the search). Smartly combining the various restrictions allows you to perform very targeted searches. - :param substring_pattern: Regular expression for a substring pattern to search for :param context_lines_before: Number of lines of context to include before each match :param context_lines_after: Number of lines of context to include after each match @@ -373,7 +372,7 @@ class SearchForPatternTool(Tool): For example, for finding classes or methods from a name pattern. Setting to False is a better choice if you also want to search in non-code files, like in html or yaml files, which is why it is the default. - :return: A mapping of file paths to lists of matched consecutive lines. + :return: A mapping from file paths to matched consecutive lines (0-based line numbers). """ abs_path = os.path.join(self.get_project_root(), relative_path) if not os.path.exists(abs_path): @@ -411,6 +410,7 @@ class SearchForPatternTool(Tool): paths_include_glob=paths_include_glob, paths_exclude_glob=paths_exclude_glob, ) + # group matches by file file_to_matches: dict[str, list[str]] = defaultdict(list) for match in matches: diff --git a/src/serena/util/text_utils.py b/src/serena/util/text_utils.py index 20c7b0b7..523ba294 100644 --- a/src/serena/util/text_utils.py +++ b/src/serena/util/text_utils.py @@ -188,17 +188,16 @@ def search_text( end_pos = match.end() # Find the line numbers for the start and end positions - start_line_num = content[:start_pos].count("\n") + 1 - end_line_num = content[:end_pos].count("\n") + 1 + start_line_num = content[:start_pos].count("\n") + end_line_num = content[:end_pos].count("\n") # Calculate the range of lines to include in the context - context_start = max(1, start_line_num - context_lines_before) - context_end = min(total_lines, end_line_num + context_lines_after) + context_start = max(0, start_line_num - context_lines_before) + context_end = min(total_lines - 1, end_line_num + context_lines_after) # Create TextLine objects for the context context_lines = [] - for i in range(context_start - 1, context_end): - line_num = i + 1 + for line_num in range(context_start, context_end + 1): if context_start <= line_num < start_line_num: match_type = LineType.BEFORE_MATCH elif end_line_num < line_num <= context_end: @@ -206,7 +205,7 @@ def search_text( else: match_type = LineType.MATCH - context_lines.append(TextLine(line_number=line_num, line_content=lines[i], match_type=match_type)) + context_lines.append(TextLine(line_number=line_num, line_content=lines[line_num], match_type=match_type)) matches.append(MatchedConsecutiveLines(lines=context_lines, source_file_path=source_file_path)) else: @@ -215,7 +214,6 @@ def search_text( # Search line by line, normal compile without DOTALL compiled_pattern = re.compile(pattern) for i, line in enumerate(lines): - line_num = i + 1 if compiled_pattern.search(line): # Calculate the range of lines to include in the context context_start = max(0, i - context_lines_before) @@ -224,7 +222,6 @@ def search_text( # Create TextLine objects for the context context_lines = [] for j in range(context_start, context_end + 1): - context_line_num = j + 1 if j < i: match_type = LineType.BEFORE_MATCH elif j > i: @@ -232,7 +229,7 @@ def search_text( else: match_type = LineType.MATCH - context_lines.append(TextLine(line_number=context_line_num, line_content=lines[j], match_type=match_type)) + context_lines.append(TextLine(line_number=j, line_content=lines[j], match_type=match_type)) matches.append(MatchedConsecutiveLines(lines=context_lines, source_file_path=source_file_path)) From 9ae2d1af7774109d88c904ec9b0970af91ac6b51 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 23 Apr 2026 15:58:37 +0200 Subject: [PATCH 24/34] Update TestSearchText to use 0-based line numbers --- test/serena/test_text_utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/serena/test_text_utils.py b/test/serena/test_text_utils.py index 8a315895..e89ccf71 100644 --- a/test/serena/test_text_utils.py +++ b/test/serena/test_text_utils.py @@ -19,8 +19,8 @@ class TestSearchText: assert len(matches) == 1 assert matches[0].num_matched_lines == 1 - assert matches[0].start_line == 3 - assert matches[0].end_line == 3 + assert matches[0].start_line == 2 + assert matches[0].end_line == 2 assert matches[0].lines[0].line_content.strip() == 'print("Hello, World!")' def test_search_text_with_regex_pattern(self): From e4da2db1d7a59b1f003b7763b9f1794e610baecd Mon Sep 17 00:00:00 2001 From: Octopus Date: Fri, 24 Apr 2026 17:41:50 +0800 Subject: [PATCH 25/34] feat: add JSON language server support via vscode-json-languageserver (#1404) * feat: add JSON language server support via vscode-json-languageserver (fixes #1391) Add support for JSON files using the `vscode-json-languageserver` npm package from Microsoft. This provides document symbol navigation and hover information for JSON files. The implementation follows the existing YAML language server pattern: - Auto-installs `vscode-json-languageserver` via npm on first use - Requires Node.js and npm - Marked as experimental (must be explicitly specified) Co-Authored-By: Octopus Co-authored-by: octo-patch Co-authored-by: Michael Panchenko <35432522+MischaPanch@users.noreply.github.com> --- CHANGELOG.md | 1 + README.md | 2 +- docs/01-about/020_programming-languages.md | 2 + pyproject.toml | 1 + .../language_servers/json_language_server.py | 164 ++++++++++++++++++ src/solidlsp/ls_config.py | 13 ++ .../repos/json/test_repo/config.json | 24 +++ test/resources/repos/json/test_repo/data.json | 30 ++++ test/solidlsp/json_ls/__init__.py | 0 test/solidlsp/json_ls/test_json_basic.py | 78 +++++++++ 10 files changed, 314 insertions(+), 1 deletion(-) create mode 100644 src/solidlsp/language_servers/json_language_server.py create mode 100644 test/resources/repos/json/test_repo/config.json create mode 100644 test/resources/repos/json/test_repo/data.json create mode 100644 test/solidlsp/json_ls/__init__.py create mode 100644 test/solidlsp/json_ls/test_json_basic.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 678be1e2..b4e968a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ Status of the `main` branch. Changes prior to the next official version change w - Fix: clangd capability checks now tolerate valid initialize response shape differences and invalidate cached C++ document symbols when clangd/compile commands context changes #1359 - Fix: `rename_symbol` for Vue files now correctly propagates edits to the TypeScript server, enabling cross-file renames in `.vue` files - Fix: Lean4 stale cache — empty document symbol responses (returned before `lake build` completes) are no longer persisted, preventing symbols from being permanently hidden #1356 + - Add JSON language server support via `vscode-json-languageserver` (experimental) #1391 Dashboard: - Add configurable dashboard interface mode (new global configuration setting `web_dashboard_interface`): diff --git a/README.md b/README.md index d26bdad8..7ee19813 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ Serena incorporates a powerful abstraction layer for the integration of language The underlying language servers are typically open-source projects or at least freely available for use. When using Serena's language server backend, we provide **support for over 40 programming languages**, including -AL, Ansible, Bash, C#, C/C++, Clojure, Crystal, Dart, Elixir, Elm, Erlang, Fortran, F#, GLSL, Go, Groovy, Haskell, Haxe, HLSL, Java, JavaScript, Julia, Kotlin, Lean 4, Lua, Luau, Markdown, MATLAB, mSL, Nix, OCaml, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, Solidity, Swift, TOML, TypeScript, WGSL, YAML, and Zig. +AL, Ansible, Bash, C#, C/C++, Clojure, Crystal, Dart, Elixir, Elm, Erlang, Fortran, F#, GLSL, Go, Groovy, Haskell, Haxe, HLSL, Java, JavaScript, JSON, Julia, Kotlin, Lean 4, Lua, Luau, Markdown, MATLAB, mSL, Nix, OCaml, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, Solidity, Swift, TOML, TypeScript, WGSL, YAML, and Zig. ### The Serena JetBrains Plugin diff --git a/docs/01-about/020_programming-languages.md b/docs/01-about/020_programming-languages.md index 999782ee..dff0a228 100644 --- a/docs/01-about/020_programming-languages.md +++ b/docs/01-about/020_programming-languages.md @@ -115,6 +115,8 @@ Some languages require additional installations or setup steps, as noted. * **Vue** (3.x with TypeScript; requires Node.js v18+ and npm; supports .vue Single File Components with monorepo detection) * **YAML** +* **JSON** + (experimental; must be explicitly added to the languages list; requires Node.js and npm) * **Zig** (requires installation of ZLS - Zig Language Server) diff --git a/pyproject.toml b/pyproject.toml index df90518d..da1aa5b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -336,6 +336,7 @@ markers = [ "haskell: Haskell language server tests", "haxe: Haxe language server tests", "yaml: language server running for YAML", + "json: language server running for JSON (uses vscode-json-languageserver)", "powershell: language server running for PowerShell", "pascal: language server running for Pascal (Free Pascal/Lazarus)", "cpp: language server running for C/C++", diff --git a/src/solidlsp/language_servers/json_language_server.py b/src/solidlsp/language_servers/json_language_server.py new file mode 100644 index 00000000..bf46992a --- /dev/null +++ b/src/solidlsp/language_servers/json_language_server.py @@ -0,0 +1,164 @@ +""" +Provides JSON specific instantiation of the LanguageServer class using vscode-json-languageserver. +Contains various configurations and settings specific to JSON files. +""" + +import logging +import os +import pathlib +import shutil +from typing import Any + +from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection, build_npm_install_command +from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + + +class JsonLanguageServer(SolidLanguageServer): + """ + Provides JSON specific instantiation of the LanguageServer class using vscode-json-languageserver. + Contains various configurations and settings specific to JSON files. + + Note: Cross-file references are not supported for JSON (the language server only provides + document symbols and hover). JSON is useful for getting a structured overview of JSON files + and navigating their contents. + """ + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """ + Creates a JsonLanguageServer instance. This class is not meant to be instantiated directly. + Use LanguageServer.create() instead. + """ + super().__init__( + config, + repository_root_path, + None, + "json", + solidlsp_settings, + ) + + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) + + class DependencyProvider(LanguageServerDependencyProviderSinglePath): + def _get_or_install_core_dependency(self) -> str: + """ + Setup runtime dependencies for JSON Language Server and return the path to the executable. + """ + is_node_installed = shutil.which("node") is not None + assert is_node_installed, "node is not installed or isn't in PATH. Please install NodeJS and try again." + is_npm_installed = shutil.which("npm") is not None + assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again." + + json_language_server_version = self._custom_settings.get("json_language_server_version", "1.3.4") + npm_registry = self._custom_settings.get("npm_registry") + + deps = RuntimeDependencyCollection( + [ + RuntimeDependency( + id="vscode-json-languageserver", + description="vscode-json-languageserver package (Microsoft)", + command=build_npm_install_command("vscode-json-languageserver", json_language_server_version, npm_registry), + platform_id="any", + ), + ] + ) + + json_ls_dir = os.path.join(self._ls_resources_dir, "json-lsp") + json_executable_path = os.path.join(json_ls_dir, "node_modules", ".bin", "vscode-json-languageserver") + + if os.name == "nt": + json_executable_path += ".cmd" + + if not os.path.exists(json_executable_path): + log.info(f"JSON Language Server executable not found at {json_executable_path}. Installing...") + deps.install(json_ls_dir) + log.info("JSON language server dependencies installed successfully") + + if not os.path.exists(json_executable_path): + raise FileNotFoundError( + f"vscode-json-languageserver executable not found at {json_executable_path}, something went wrong with the installation." + ) + + return json_executable_path + + def _create_launch_command(self, core_path: str) -> list[str]: + return [core_path, "--stdio"] + + @staticmethod + def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + """ + Returns the initialize params for the JSON Language Server. + """ + root_uri = pathlib.Path(repository_absolute_path).as_uri() + initialize_params = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}}, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + }, + }, + "processId": os.getpid(), + "rootPath": repository_absolute_path, + "rootUri": root_uri, + "workspaceFolders": [ + { + "uri": root_uri, + "name": os.path.basename(repository_absolute_path), + } + ], + } + return initialize_params # type: ignore + + def _start_server(self) -> None: + """ + Starts the JSON Language Server, waits for the server to be ready and yields the LanguageServer instance. + """ + + def register_capability_handler(params: Any) -> None: + return + + def do_nothing(params: Any) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + log.info("Starting JSON server process") + self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) + + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + log.debug(f"Received initialize response from JSON server: {init_response}") + + if "documentSymbolProvider" in init_response.get("capabilities", {}): + log.info("JSON server supports document symbols") + else: + log.warning("Warning: JSON server does not report document symbol support") + + self.server.notify.initialized({}) + + log.info("JSON server initialization complete") diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index ff137894..eb527dc3 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -122,6 +122,12 @@ class Language(str, Enum): """YAML language server (experimental). Must be explicitly specified as the main language, not auto-detected. """ + JSON = "json" + """JSON language server using vscode-json-languageserver (experimental). + Provides document symbol navigation and hover for JSON files. + Must be explicitly specified as the main language, not auto-detected. + Requires Node.js and npm. + """ TOML = "toml" """TOML language server using Taplo. Supports TOML validation, formatting, and schema support. @@ -174,6 +180,7 @@ class Language(str, Enum): self.PHP_PHPACTOR, self.MARKDOWN, self.YAML, + self.JSON, self.TOML, self.GROOVY, self.CPP_CCLS, @@ -252,6 +259,8 @@ class Language(str, Enum): return FilenameMatcher("*.cr") case self.YAML: return FilenameMatcher("*.yaml", "*.yml") + case self.JSON: + return FilenameMatcher("*.json", "*.jsonc") case self.TOML: return FilenameMatcher("*.toml") case self.ZIG: @@ -446,6 +455,10 @@ class Language(str, Enum): from solidlsp.language_servers.yaml_language_server import YamlLanguageServer return YamlLanguageServer + case self.JSON: + from solidlsp.language_servers.json_language_server import JsonLanguageServer + + return JsonLanguageServer case self.TOML: from solidlsp.language_servers.taplo_server import TaploServer diff --git a/test/resources/repos/json/test_repo/config.json b/test/resources/repos/json/test_repo/config.json new file mode 100644 index 00000000..78ec641a --- /dev/null +++ b/test/resources/repos/json/test_repo/config.json @@ -0,0 +1,24 @@ +{ + "app": { + "name": "test-application", + "version": "1.0.0", + "port": 8080, + "debug": true + }, + "database": { + "host": "localhost", + "port": 5432, + "name": "testdb", + "username": "admin" + }, + "logging": { + "level": "info", + "format": "json", + "outputs": ["console", "file"] + }, + "features": { + "authentication": true, + "caching": false, + "monitoring": true + } +} diff --git a/test/resources/repos/json/test_repo/data.json b/test/resources/repos/json/test_repo/data.json new file mode 100644 index 00000000..329491ba --- /dev/null +++ b/test/resources/repos/json/test_repo/data.json @@ -0,0 +1,30 @@ +{ + "users": [ + { + "id": 1, + "name": "Alice", + "email": "alice@example.com", + "roles": ["admin", "user"] + }, + { + "id": 2, + "name": "Bob", + "email": "bob@example.com", + "roles": ["user"] + } + ], + "projects": [ + { + "id": 101, + "name": "Project Alpha", + "status": "active", + "tags": ["backend", "api"] + }, + { + "id": 102, + "name": "Project Beta", + "status": "planning", + "tags": ["frontend"] + } + ] +} diff --git a/test/solidlsp/json_ls/__init__.py b/test/solidlsp/json_ls/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/solidlsp/json_ls/test_json_basic.py b/test/solidlsp/json_ls/test_json_basic.py new file mode 100644 index 00000000..c2e8031a --- /dev/null +++ b/test/solidlsp/json_ls/test_json_basic.py @@ -0,0 +1,78 @@ +""" +Basic integration tests for the JSON language server functionality. + +These tests validate the functionality of the language server APIs +like request_document_symbols using the JSON test repository. +""" + +from pathlib import Path + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols + + +@pytest.mark.json +class TestJsonLanguageServerBasics: + """Test basic functionality of the JSON language server.""" + + @pytest.mark.parametrize("language_server", [Language.JSON], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.JSON], indirect=True) + def test_json_language_server_initialization(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that JSON language server can be initialized successfully.""" + assert language_server is not None + assert language_server.language == Language.JSON + assert language_server.is_running() + assert Path(language_server.language_server.repository_root_path).resolve() == repo_path.resolve() + + @pytest.mark.parametrize("language_server", [Language.JSON], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.JSON], indirect=True) + def test_json_config_file_symbols(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test document symbols detection in config.json with specific symbol verification.""" + all_symbols, root_symbols = language_server.request_document_symbols("config.json").get_all_symbols_and_roots() + + assert all_symbols is not None, "Should return symbols for config.json" + assert len(all_symbols) > 0, f"Should find symbols in config.json, found {len(all_symbols)}" + + symbol_names = [sym.get("name") for sym in all_symbols] + assert "app" in symbol_names, "Should detect 'app' key in config.json" + assert "database" in symbol_names, "Should detect 'database' key in config.json" + assert "logging" in symbol_names, "Should detect 'logging' key in config.json" + assert "features" in symbol_names, "Should detect 'features' key in config.json" + + # Verify nested symbols + assert "name" in symbol_names, "Should detect nested 'name' key" + assert "port" in symbol_names, "Should detect nested 'port' key" + assert "debug" in symbol_names, "Should detect nested 'debug' key" + + @pytest.mark.parametrize("language_server", [Language.JSON], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.JSON], indirect=True) + def test_json_data_file_symbols(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test symbol detection in data.json with array structures.""" + all_symbols, root_symbols = language_server.request_document_symbols("data.json").get_all_symbols_and_roots() + + assert all_symbols is not None, "Should return symbols for data.json" + assert len(all_symbols) > 0, f"Should find symbols in data.json, found {len(all_symbols)}" + + symbol_names = [sym.get("name") for sym in all_symbols] + assert "users" in symbol_names, "Should detect 'users' array" + assert "projects" in symbol_names, "Should detect 'projects' array" + assert "name" in symbol_names, "Should detect 'name' fields" + assert "email" in symbol_names, "Should detect 'email' fields" + assert "id" in symbol_names, "Should detect 'id' fields" + + @pytest.mark.parametrize("language_server", [Language.JSON], indirect=True) + def test_bare_symbol_names(self, language_server: SolidLanguageServer) -> None: + """Test that symbol names do not contain malformed characters.""" + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s, period_allowed=True): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) From bc41c840eae77f7a550cf6c91c0eeb3e879380f7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 11:42:51 +0200 Subject: [PATCH 26/34] Bump python-dotenv in the uv group across 1 directory (#1395) Bumps the uv group with 1 update in the / directory: [python-dotenv](https://github.com/theskumar/python-dotenv). Updates `python-dotenv` from 1.2.1 to 1.2.2 - [Release notes](https://github.com/theskumar/python-dotenv/releases) - [Changelog](https://github.com/theskumar/python-dotenv/blob/main/CHANGELOG.md) - [Commits](https://github.com/theskumar/python-dotenv/compare/v1.2.1...v1.2.2) --- updated-dependencies: - dependency-name: python-dotenv dependency-version: 1.2.2 dependency-type: direct:production dependency-group: uv ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index da1aa5b9..6df80045 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ dependencies = [ "pyright==1.1.403", "fortls==3.2.2", "overrides==7.7.0", - "python-dotenv==1.2.1", + "python-dotenv==1.2.2", "mcp==1.26.0", "flask==3.1.3", # bumped from 3.1.1 for CVE fix (also fixes werkzeug alert) "sensai-utils==1.5.0", diff --git a/uv.lock b/uv.lock index f5f91eff..b953940e 100644 --- a/uv.lock +++ b/uv.lock @@ -2317,11 +2317,11 @@ wheels = [ [[package]] name = "python-dotenv" -version = "1.2.1" +version = "1.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] [[package]] @@ -2960,7 +2960,7 @@ requires-dist = [ { name = "pytest", marker = "extra == 'dev'", specifier = "==8.4.1" }, { name = "pytest-timeout", marker = "extra == 'dev'", specifier = "==2.4.0" }, { name = "pytest-xdist", marker = "extra == 'dev'", specifier = "==3.8.0" }, - { name = "python-dotenv", specifier = "==1.2.1" }, + { name = "python-dotenv", specifier = "==1.2.2" }, { name = "python-multipart", specifier = "==0.0.26" }, { name = "pythonnet", marker = "sys_platform == 'win32'", specifier = "==3.1.0rc0" }, { name = "pywebview", specifier = "==6.2" }, From 64582cebe218b51f6fde8be6cfe30eaf51feef90 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 24 Apr 2026 13:23:35 +0200 Subject: [PATCH 27/34] Provide Serena's system prompt (instruction manual) lazily At MCP connection time, provide only a one-sentence bootstrap prompt --- CHANGELOG.md | 3 +++ src/serena/agent.py | 11 ++++++++++- src/serena/generated/generated_prompt_factory.py | 16 ++-------------- src/serena/mcp.py | 3 ++- .../config/prompt_templates/system_prompt.yml | 2 ++ 5 files changed, 19 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4e968a6..1a9c4006 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,9 @@ Status of the `main` branch. Changes prior to the next official version change w - `query_project`: Support use of project root instead of project name #1388 - `list_queryable_projects`: Return both project names and project roots - Fix: `search_for_pattern` tool returned 1-based line numbers (in contrast to all other tools); cause: implementation of `text_utils.search_text` + - Serena's system prompt (a.k.a. the 'Serena Instructions Manual') is now provided lazily. + At MCP connection time, only a one-sentence bootstrap prompt is provided. + The `initial_instructions` tool provides the full prompt on demand, keeping the initial context lean. * JetBrains: - `Move` and `SafeDelete` tools: transform empty string to None (counteracts client errors) diff --git a/src/serena/agent.py b/src/serena/agent.py index e3800b97..c4062a5a 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -891,8 +891,18 @@ class SerenaAgent: template = JinjaTemplate(prompt_template) return template.render(available_tools=self._exposed_tools.tool_names, available_markers=self._exposed_tools.tool_marker_names) + def create_connection_prompt(self) -> str: + """ + Returns the bootstrap prompt to be sent at MCP connection time. + + :return: the prompt + """ + return self.prompt_factory.create_connection_prompt() + def create_system_prompt(self, session_id: str = "global") -> str: """ + Returns the 'Serena Instructions Manual', i.e. Serena's system prompt. + :param session_id: the client session ID for the case where this is run from a tool; "global" for the connection time case :return: the prompt """ @@ -925,7 +935,6 @@ class SerenaAgent: if self._active_project is not None and not self._project_prompt_status.is_project_activation_message_already_provided(session_id): system_prompt += "\n\n" + self.get_project_activation_message(session_id) - log.info("System prompt:\n%s", system_prompt) return system_prompt def get_project_activation_message(self, session_id: str) -> str: diff --git a/src/serena/generated/generated_prompt_factory.py b/src/serena/generated/generated_prompt_factory.py index 9129318c..58070fd2 100644 --- a/src/serena/generated/generated_prompt_factory.py +++ b/src/serena/generated/generated_prompt_factory.py @@ -17,20 +17,8 @@ class PromptFactory(PromptFactoryBase): def create_onboarding_prompt(self, *, system: Any) -> str: return self._render_prompt("onboarding_prompt", locals()) - def create_think_about_collected_information(self) -> str: - return self._render_prompt("think_about_collected_information", locals()) - - def create_think_about_task_adherence(self) -> str: - return self._render_prompt("think_about_task_adherence", locals()) - - def create_think_about_whether_you_are_done(self) -> str: - return self._render_prompt("think_about_whether_you_are_done", locals()) - - def create_summarize_changes(self) -> str: - return self._render_prompt("summarize_changes", locals()) - - def create_prepare_for_new_conversation(self) -> str: - return self._render_prompt("prepare_for_new_conversation", locals()) + def create_connection_prompt(self) -> str: + return self._render_prompt("connection_prompt", locals()) def create_system_prompt( self, diff --git a/src/serena/mcp.py b/src/serena/mcp.py index a8252846..13b9a00c 100644 --- a/src/serena/mcp.py +++ b/src/serena/mcp.py @@ -332,6 +332,7 @@ class SerenaMCPFactory: # retain only FASTMCP_ prefix for already set environment variables. Settings.model_config = SettingsConfigDict(env_prefix="FASTMCP_") instructions = self._get_initial_instructions() + log.info("MCP server initial instructions:\n%s", instructions) mcp = FastMCP( name="Serena", lifespan=self.server_lifespan, @@ -357,4 +358,4 @@ class SerenaMCPFactory: def _get_initial_instructions(self) -> str: assert self.agent is not None - return self.agent.create_system_prompt() + return self.agent.create_connection_prompt() diff --git a/src/serena/resources/config/prompt_templates/system_prompt.yml b/src/serena/resources/config/prompt_templates/system_prompt.yml index 35fffeda..426ba0be 100644 --- a/src/serena/resources/config/prompt_templates/system_prompt.yml +++ b/src/serena/resources/config/prompt_templates/system_prompt.yml @@ -2,6 +2,8 @@ # in which case this prompt will be given as a regular message on the call of a simple tool which the agent # is encouraged (via the tool description) to call at the beginning of the conversation. prompts: + connection_prompt: | + CRITICAL: Before starting to work on a coding task, call the `initial_instructions` tool to read the 'Serena Instructions Manual'. system_prompt: | You have access to semantic coding tools upon which you rely heavily for all your work. You operate in a resource-efficient and intelligent manner, always keeping in mind to not read or generate From 3223c82609d5870477a5f92fe20c9b7392e48e80 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Wed, 22 Apr 2026 01:00:34 +0200 Subject: [PATCH 28/34] Add JetBrainsDebugTool --- .../jetbrains/jetbrains_plugin_client.py | 26 ++++++ .../config/internal_modes/jetbrains.yml | 1 + src/serena/tools/jetbrains_tools.py | 79 +++++++++++++++++++ 3 files changed, 106 insertions(+) diff --git a/src/serena/jetbrains/jetbrains_plugin_client.py b/src/serena/jetbrains/jetbrains_plugin_client.py index d65925ea..61a6a563 100644 --- a/src/serena/jetbrains/jetbrains_plugin_client.py +++ b/src/serena/jetbrains/jetbrains_plugin_client.py @@ -633,6 +633,32 @@ class JetBrainsPluginClient(ToStringMixin): self._postprocess_symbol_collection_response(symbol_collection) return symbol_collection + def debug_eval(self, session_key: str, expression: str) -> dict[str, Any]: + """ + Evaluates a Groovy expression in the persistent debug REPL. + + :param session_key: the session key identifying the REPL instance + :param expression: the Groovy expression to evaluate + :return: the response containing sessionKey and result + """ + request_data = { + "sessionKey": session_key, + "expression": expression, + } + return self._make_request("POST", "/debugEval", request_data) + + def debug_reset(self, session_key: str) -> dict[str, Any]: + """ + Resets the debug REPL for the given session key, clearing all state. + + :param session_key: the session key identifying the REPL instance to reset + :return: the status response + """ + request_data = { + "sessionKey": session_key, + } + return self._make_request("POST", "/debugReset", request_data) + def close(self) -> None: self._session.close() diff --git a/src/serena/resources/config/internal_modes/jetbrains.yml b/src/serena/resources/config/internal_modes/jetbrains.yml index a08b189f..7f76271f 100644 --- a/src/serena/resources/config/internal_modes/jetbrains.yml +++ b/src/serena/resources/config/internal_modes/jetbrains.yml @@ -23,3 +23,4 @@ included_optional_tools: - jet_brains_type_hierarchy - jet_brains_rename - jet_brains_safe_delete + - jet_brains_debug diff --git a/src/serena/tools/jetbrains_tools.py b/src/serena/tools/jetbrains_tools.py index 00d0adbd..dc17009f 100644 --- a/src/serena/tools/jetbrains_tools.py +++ b/src/serena/tools/jetbrains_tools.py @@ -565,3 +565,82 @@ class JetBrainsRenameTool(Tool, ToolMarkerSymbolicEdit, ToolMarkerOptional): rename_in_text_occurrences=rename_in_text_occurrences, ) return self._to_json(result) + + +class JetBrainsDebugTool(Tool, ToolMarkerBeta): + """ + Evaluates Groovy/Java expressions in a persistent debug REPL connected to the JetBrains IDE. + Provides full debugger control: run configs, breakpoints, stepping, inspection, and evaluation. + """ + + def apply( + self, + expression: str, + session_key: str = "default", + ) -> str: + """ + Evaluate a Groovy/Java expression in the persistent debug REPL. State (variables, objects) + persists across calls within the same session_key. + + Pre-bound variables: ``debug`` (DebuggerFacade), ``project`` (IntelliJ Project). + + **Run configurations**:: + + debug.listRunConfigs() + debug.startDebug("MyTestRun") # returns a DebugSession + debug.runConfig("MyApp") # non-debug run, fire-and-forget + + **Session discovery**:: + + debug.sessions() + debug.currentSession() + debug.session(id) + + **Breakpoints** (project-global, set before or during debugging):: + + debug.addBreakpoint(file, line) + debug.addBreakpoint(file, line, condition) + debug.addLogpoint(file, line, logExpression) + debug.listBreakpoints() + debug.removeBreakpointAt(file, line) + + **Execution control** (on a DebugSession, e.g. ``session``):: + + session.stepOver() / .stepInto() / .stepOut() + session.resume() / .pause() / .stop() + session.runToLine(file, line) + + **Wait for pause** (blocks until the session pauses or times out):: + + session.waitForPause(timeoutSeconds) + session.stepOverAndWait(timeoutSeconds) # step + wait in one call + session.resumeAndWait(timeoutSeconds) + + **Inspection**:: + + session.status() # location + source + variables + stack in one call + session.variables() # all locals/args + session.variable(name) # single variable + session.frames() / .frame() # stack trace / current frame + session.sourceContext(lines) # source around current line + session.threads() + + **Evaluation & modification**:: + + session.eval("expr") # evaluate in current frame + session.setVar("x", "42") # set variable via assignment + + **Example**:: + + session = debug.startDebug("MyTest") + debug.addBreakpoint("src/main/java/Foo.java", 42) + session.waitForPause(30) + session.status() + + :param expression: a Groovy/Java expression to evaluate in the REPL + :param session_key: identifies the REPL instance. State persists across calls with the same key. + :return: the string representation of the expression's result + """ + with JetBrainsPluginClient.from_project(self.project) as client: + response = client.debug_eval(session_key=session_key, expression=expression) + return response.get("result", str(response)) From f678d81cac5becb1756164652765e9765daacd89 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Wed, 22 Apr 2026 11:51:22 +0200 Subject: [PATCH 29/34] Improve debugger interface and instructions --- .../jetbrains/jetbrains_plugin_client.py | 20 ++--- src/serena/tools/jetbrains_tools.py | 86 +++++++++++-------- 2 files changed, 59 insertions(+), 47 deletions(-) diff --git a/src/serena/jetbrains/jetbrains_plugin_client.py b/src/serena/jetbrains/jetbrains_plugin_client.py index 61a6a563..4987a4ef 100644 --- a/src/serena/jetbrains/jetbrains_plugin_client.py +++ b/src/serena/jetbrains/jetbrains_plugin_client.py @@ -633,31 +633,31 @@ class JetBrainsPluginClient(ToStringMixin): self._postprocess_symbol_collection_response(symbol_collection) return symbol_collection - def debug_eval(self, session_key: str, expression: str) -> dict[str, Any]: + def debug_eval(self, repl_key: str, expression: str) -> dict[str, Any]: """ Evaluates a Groovy expression in the persistent debug REPL. - :param session_key: the session key identifying the REPL instance + :param repl_key: the session key identifying the REPL instance :param expression: the Groovy expression to evaluate - :return: the response containing sessionKey and result + :return: the response containing REPL key and result """ request_data = { - "sessionKey": session_key, + "replKey": repl_key, "expression": expression, } - return self._make_request("POST", "/debugEval", request_data) + return self._make_request("POST", "/debugReplEval", request_data) - def debug_reset(self, session_key: str) -> dict[str, Any]: + def debug_close(self, repl_key: str) -> dict[str, Any]: """ - Resets the debug REPL for the given session key, clearing all state. + Closes the debug REPL for the given session key, clearing all state. - :param session_key: the session key identifying the REPL instance to reset + :param repl_key: the key identifying the REPL instance to close :return: the status response """ request_data = { - "sessionKey": session_key, + "replKey": repl_key, } - return self._make_request("POST", "/debugReset", request_data) + return self._make_request("POST", "/debugReplClose", request_data) def close(self) -> None: self._session.close() diff --git a/src/serena/tools/jetbrains_tools.py b/src/serena/tools/jetbrains_tools.py index dc17009f..58076ddc 100644 --- a/src/serena/tools/jetbrains_tools.py +++ b/src/serena/tools/jetbrains_tools.py @@ -567,80 +567,92 @@ class JetBrainsRenameTool(Tool, ToolMarkerSymbolicEdit, ToolMarkerOptional): return self._to_json(result) -class JetBrainsDebugTool(Tool, ToolMarkerBeta): +class JetBrainsDebugTool(Tool, ToolMarkerOptional, ToolMarkerBeta): """ - Evaluates Groovy/Java expressions in a persistent debug REPL connected to the JetBrains IDE. - Provides full debugger control: run configs, breakpoints, stepping, inspection, and evaluation. + Provides debugging functionality (run configs, breakpoints, stepping, inspection, and evaluation) + via a persistent debug REPL connected to the JetBrains IDE. """ def apply( self, expression: str, - session_key: str = "default", + repl_key: str = "default", ) -> str: """ - Evaluate a Groovy/Java expression in the persistent debug REPL. State (variables, objects) - persists across calls within the same session_key. + Evaluates a Groovy expression in a persistent REPL, which can be used to debug + code interactively. + Important: Debugging should only be applied if the user has requested it! + You rely on the user to provide suitable run configurations for you to work with. - Pre-bound variables: ``debug`` (DebuggerFacade), ``project`` (IntelliJ Project). + Note: The debugger uses 0-based line numbers like other Serena tools (while humans typically communicate 1-based ones). - **Run configurations**:: + State (objects, variables assigned) persists across calls within the same repl_key. + Pre-bound variables: ``debug`` (debugger interface) + + Run configurations: debug.listRunConfigs() - debug.startDebug("MyTestRun") # returns a DebugSession - debug.runConfig("MyApp") # non-debug run, fire-and-forget - - **Session discovery**:: + session = debug.startDebug("MyTestRun") # returns a DebugSession + debug.startRun("MyApp") # non-debug run, fire-and-forget + Session discovery: debug.sessions() - debug.currentSession() - debug.session(id) - - **Breakpoints** (project-global, set before or during debugging):: + session = debug.currentSession() + session = debug.session(id) + Breakpoints (project-global, set before or during debugging): debug.addBreakpoint(file, line) debug.addBreakpoint(file, line, condition) debug.addLogpoint(file, line, logExpression) debug.listBreakpoints() debug.removeBreakpointAt(file, line) - **Execution control** (on a DebugSession, e.g. ``session``):: - - session.stepOver() / .stepInto() / .stepOut() - session.resume() / .pause() / .stop() + Execution control (on a DebugSession, e.g. ``session``):: + session.stepOver() | .stepInto() | .stepOut() + session.resume() | .pause() | .stop() session.runToLine(file, line) - **Wait for pause** (blocks until the session pauses or times out):: - + Wait for pause (blocks until the session pauses or times out): session.waitForPause(timeoutSeconds) session.stepOverAndWait(timeoutSeconds) # step + wait in one call session.resumeAndWait(timeoutSeconds) - **Inspection**:: - - session.status() # location + source + variables + stack in one call - session.variables() # all locals/args - session.variable(name) # single variable - session.frames() / .frame() # stack trace / current frame + Inspection (when paused): + session.status() # location + source + variable values in one call + session.variables() # overview of locals/args (values may be truncated) + session.variable(name) # single variable (VariableInfo instance) + session.frames() | .frame() # stack trace | current frame session.sourceContext(lines) # source around current line session.threads() - **Evaluation & modification**:: + VariableInfo members: name, value (String as presented by the debugger), type - session.eval("expr") # evaluate in current frame + Evaluation & modification: + session.eval("expr") # evaluate in current frame session.setVar("x", "42") # set variable via assignment - **Example**:: - - session = debug.startDebug("MyTest") + Example sequence of expressions one might execute in a REPL: debug.addBreakpoint("src/main/java/Foo.java", 42) + session = debug.startDebug("MyTest") session.waitForPause(30) session.status() - :param expression: a Groovy/Java expression to evaluate in the REPL - :param session_key: identifies the REPL instance. State persists across calls with the same key. - :return: the string representation of the expression's result + You can pass multiple statements at once; the result of the final expression is returned: + session = debug.startDebug("MyTest"); session.waitForPause(30); session.status() + + Control flow statements are also possible: + while (session.variable("count").value.equals("0")) { session.stepOverAndWait(5) } + + When done, close the REPL by passing an empty expression. + + :param expression: a Groovy/Java expression/statement to evaluate in the REPL. + If empty/null, closes the REPL with the given key, discarding its state. + :param repl_key: identifier for the REPL instance. State persists across calls with the same key. + :return: string representation of the result """ with JetBrainsPluginClient.from_project(self.project) as client: - response = client.debug_eval(session_key=session_key, expression=expression) + if expression: + response = client.debug_eval(repl_key=repl_key, expression=expression) + else: + response = client.debug_close(repl_key=repl_key) return response.get("result", str(response)) From b331183c76864a5ab540ee19f6d582ed55e9efce Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 23 Apr 2026 15:53:42 +0200 Subject: [PATCH 30/34] Update documentation to mention JetBrains debug tool --- CHANGELOG.md | 4 +++- README.md | 8 +++++++- docs/02-usage/025_jetbrains_plugin.md | 2 ++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a9c4006..c467cc96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,9 @@ Status of the `main` branch. Changes prior to the next official version change w The `initial_instructions` tool provides the full prompt on demand, keeping the initial context lean. * JetBrains: - - `Move` and `SafeDelete` tools: transform empty string to None (counteracts client errors) + - Add `debug` tool: The agent can set breakpoints, inspect variables, evaluate expressions and control execution flow + by directly interacting with the IDE's debugger, using a REPL-style interface for maximum flexibility. + - `move` and `safe_delete` tools: transform empty string to None (counteracts client errors) * Dependencies: - `pywebview`: Switch back to official release (new version 6.2) #1253 diff --git a/README.md b/README.md index 7ee19813..0c819fb4 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@
-* Serena provides essential **semantic code retrieval, editing and refactoring tools** that are akin to an IDE's capabilities, +* Serena provides essential **semantic code retrieval, editing, refactoring and debugging tools** that are akin to an IDE's capabilities, operating at the symbol level and exploiting relational structure. * It integrates with any client/LLM via the model context protocol (**MCP**). @@ -171,6 +171,12 @@ Serena's symbolic editing tools are less error-prone and much more token-efficie | insert before symbol | yes | yes | | safe delete | yes | yes | +### Interactive Debugging + +Exclusive to the JetBrains plugin, Serena supports a highly general debugging tool, +which allows an agent to set breakpoints, inspect variables, evaluate expressions and control execution flow +via a persistent REPL-style interface. + ### Basic Features Beyond its semantic capabilities, Serena includes a set of basic utilities for completeness. diff --git a/docs/02-usage/025_jetbrains_plugin.md b/docs/02-usage/025_jetbrains_plugin.md index 1995ca34..4eb08688 100644 --- a/docs/02-usage/025_jetbrains_plugin.md +++ b/docs/02-usage/025_jetbrains_plugin.md @@ -43,6 +43,8 @@ There are multiple features that are only available when using the JetBrains plu * **Enhanced retrieval & refactoring capabilities**: The plugin adds additional [tools](../01-about/035_tools) (e.g. type hierarchy retrieval, move, find declaration, inline symbol, etc.) and transforms the underlying mechanisms of shared tools to build upon the IDE's capabilities. +* **Interactive debugging**: The agent can set breakpoints, inspect variables, evaluate expressions and control execution flow + by directly interacting with the IDE's debugger, using a REPL-style interface for maximum flexibility. * **Improved multi-agent support**: A single IDE instance naturally serves arbitrarily many agent sessions without requiring additional resources. * **Enhanced performance**: Faster tool execution thanks to optimized IDE integration. * **Multi-language excellence** and **framework support**: First-class support for polyglot projects with multiple languages. From 3b71b3f306c8287df20cbef3ecc1771f6f2ab329 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 23 Apr 2026 16:14:39 +0200 Subject: [PATCH 31/34] Add version guard for debug tool --- src/serena/jetbrains/jetbrains_plugin_client.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/serena/jetbrains/jetbrains_plugin_client.py b/src/serena/jetbrains/jetbrains_plugin_client.py index 4987a4ef..263bc8a9 100644 --- a/src/serena/jetbrains/jetbrains_plugin_client.py +++ b/src/serena/jetbrains/jetbrains_plugin_client.py @@ -641,6 +641,7 @@ class JetBrainsPluginClient(ToStringMixin): :param expression: the Groovy expression to evaluate :return: the response containing REPL key and result """ + self._require_version_at_least(2023, 2, 16) request_data = { "replKey": repl_key, "expression": expression, @@ -654,6 +655,7 @@ class JetBrainsPluginClient(ToStringMixin): :param repl_key: the key identifying the REPL instance to close :return: the status response """ + self._require_version_at_least(2023, 2, 16) request_data = { "replKey": repl_key, } From 1fd0fe2cb5b962060d98de4a3e759e0fbd2c4aec Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 24 Apr 2026 13:44:30 +0200 Subject: [PATCH 32/34] Add SerenaInfoTool for on-demand retrieval of usage information Apply it to provide JetBrains debug REPL information lazily --- CHANGELOG.md | 1 + .../generated/generated_prompt_factory.py | 3 + .../config/internal_modes/jetbrains.yml | 1 + .../config/prompt_templates/info_prompts.yml | 64 ++++++++++++++++++ src/serena/tools/jetbrains_tools.py | 67 +------------------ src/serena/tools/workflow_tools.py | 19 +++++- 6 files changed, 90 insertions(+), 65 deletions(-) create mode 100644 src/serena/resources/config/prompt_templates/info_prompts.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index c467cc96..c6e1ba4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ Status of the `main` branch. Changes prior to the next official version change w - Serena's system prompt (a.k.a. the 'Serena Instructions Manual') is now provided lazily. At MCP connection time, only a one-sentence bootstrap prompt is provided. The `initial_instructions` tool provides the full prompt on demand, keeping the initial context lean. + - Add `serena_info` tool for on-demand retrieval of usage information * JetBrains: - Add `debug` tool: The agent can set breakpoints, inspect variables, evaluate expressions and control execution flow diff --git a/src/serena/generated/generated_prompt_factory.py b/src/serena/generated/generated_prompt_factory.py index 58070fd2..43f866d7 100644 --- a/src/serena/generated/generated_prompt_factory.py +++ b/src/serena/generated/generated_prompt_factory.py @@ -14,6 +14,9 @@ class PromptFactory(PromptFactoryBase): A class for retrieving and rendering prompt templates and prompt lists. """ + def create_info_jet_brains_debug_repl(self) -> str: + return self._render_prompt("info_jet_brains_debug_repl", locals()) + def create_onboarding_prompt(self, *, system: Any) -> str: return self._render_prompt("onboarding_prompt", locals()) diff --git a/src/serena/resources/config/internal_modes/jetbrains.yml b/src/serena/resources/config/internal_modes/jetbrains.yml index 7f76271f..34baf6ce 100644 --- a/src/serena/resources/config/internal_modes/jetbrains.yml +++ b/src/serena/resources/config/internal_modes/jetbrains.yml @@ -24,3 +24,4 @@ included_optional_tools: - jet_brains_rename - jet_brains_safe_delete - jet_brains_debug + - serena_info diff --git a/src/serena/resources/config/prompt_templates/info_prompts.yml b/src/serena/resources/config/prompt_templates/info_prompts.yml new file mode 100644 index 00000000..4c896429 --- /dev/null +++ b/src/serena/resources/config/prompt_templates/info_prompts.yml @@ -0,0 +1,64 @@ +# Defines prompts the SerenaInfoTool +prompts: + info_jet_brains_debug_repl: | + Note: The debugger uses 0-based line numbers like other Serena tools (while humans typically communicate 1-based ones). + You rely on the user to provide suitable run configurations for you to work with. + + State (objects, variables assigned) persists across calls within the same repl_key. + + Pre-bound variables: ``debug`` (debugger interface) + + Run configurations: + debug.listRunConfigs() + session = debug.startDebug("MyTestRun") # returns a DebugSession + debug.startRun("MyApp") # non-debug run, fire-and-forget + + Session discovery: + debug.sessions() + session = debug.currentSession() + session = debug.session(id) + + Breakpoints (project-global, set before or during debugging): + debug.addBreakpoint(file, line) + debug.addBreakpoint(file, line, condition) + debug.addLogpoint(file, line, logExpression) + debug.listBreakpoints() + debug.removeBreakpointAt(file, line) + + Execution control (on a DebugSession, e.g. ``session``):: + session.stepOver() | .stepInto() | .stepOut() + session.resume() | .pause() | .stop() + session.runToLine(file, line) + + Wait for pause (blocks until the session pauses or times out): + session.waitForPause(timeoutSeconds) + session.stepOverAndWait(timeoutSeconds) # step + wait in one call + session.resumeAndWait(timeoutSeconds) + + Inspection (when paused): + session.status() # location + source + variable values in one call + session.variables() # overview of locals/args (values may be truncated) + session.variable(name) # single variable (VariableInfo instance) + session.frames() | .frame() # stack trace | current frame + session.sourceContext(lines) # source around current line + session.threads() + + VariableInfo members: name, value (String as presented by the debugger), type + + Evaluation & modification: + session.eval("expr") # evaluate in current frame + session.setVar("x", "42") # set variable via assignment + + Example sequence of expressions one might execute in a REPL: + debug.addBreakpoint("src/main/java/Foo.java", 42) + session = debug.startDebug("MyTest") + session.waitForPause(30) + session.status() + + You can pass multiple statements at once; the result of the final expression is returned: + session = debug.startDebug("MyTest"); session.waitForPause(30); session.status() + + Control flow statements are also possible: + while (session.variable("count").value.equals("0")) { session.stepOverAndWait(5) } + + When done, close the REPL by passing an empty expression. diff --git a/src/serena/tools/jetbrains_tools.py b/src/serena/tools/jetbrains_tools.py index 58076ddc..0664fb03 100644 --- a/src/serena/tools/jetbrains_tools.py +++ b/src/serena/tools/jetbrains_tools.py @@ -579,74 +579,13 @@ class JetBrainsDebugTool(Tool, ToolMarkerOptional, ToolMarkerBeta): repl_key: str = "default", ) -> str: """ - Evaluates a Groovy expression in a persistent REPL, which can be used to debug - code interactively. + Debug code interactively by evaluating Groovy expressions in a persistent REPL. Important: Debugging should only be applied if the user has requested it! - You rely on the user to provide suitable run configurations for you to work with. - Note: The debugger uses 0-based line numbers like other Serena tools (while humans typically communicate 1-based ones). - - State (objects, variables assigned) persists across calls within the same repl_key. - - Pre-bound variables: ``debug`` (debugger interface) - - Run configurations: - debug.listRunConfigs() - session = debug.startDebug("MyTestRun") # returns a DebugSession - debug.startRun("MyApp") # non-debug run, fire-and-forget - - Session discovery: - debug.sessions() - session = debug.currentSession() - session = debug.session(id) - - Breakpoints (project-global, set before or during debugging): - debug.addBreakpoint(file, line) - debug.addBreakpoint(file, line, condition) - debug.addLogpoint(file, line, logExpression) - debug.listBreakpoints() - debug.removeBreakpointAt(file, line) - - Execution control (on a DebugSession, e.g. ``session``):: - session.stepOver() | .stepInto() | .stepOut() - session.resume() | .pause() | .stop() - session.runToLine(file, line) - - Wait for pause (blocks until the session pauses or times out): - session.waitForPause(timeoutSeconds) - session.stepOverAndWait(timeoutSeconds) # step + wait in one call - session.resumeAndWait(timeoutSeconds) - - Inspection (when paused): - session.status() # location + source + variable values in one call - session.variables() # overview of locals/args (values may be truncated) - session.variable(name) # single variable (VariableInfo instance) - session.frames() | .frame() # stack trace | current frame - session.sourceContext(lines) # source around current line - session.threads() - - VariableInfo members: name, value (String as presented by the debugger), type - - Evaluation & modification: - session.eval("expr") # evaluate in current frame - session.setVar("x", "42") # set variable via assignment - - Example sequence of expressions one might execute in a REPL: - debug.addBreakpoint("src/main/java/Foo.java", 42) - session = debug.startDebug("MyTest") - session.waitForPause(30) - session.status() - - You can pass multiple statements at once; the result of the final expression is returned: - session = debug.startDebug("MyTest"); session.waitForPause(30); session.status() - - Control flow statements are also possible: - while (session.variable("count").value.equals("0")) { session.stepOverAndWait(5) } - - When done, close the REPL by passing an empty expression. + Use the `serena_info` tool with topic `jet_brains_debug_repl` for usage information. :param expression: a Groovy/Java expression/statement to evaluate in the REPL. - If empty/null, closes the REPL with the given key, discarding its state. + If empty/null, closes the REPL with the given key. :param repl_key: identifier for the REPL instance. State persists across calls with the same key. :return: string representation of the result """ diff --git a/src/serena/tools/workflow_tools.py b/src/serena/tools/workflow_tools.py index e50ec9b5..461da9ea 100644 --- a/src/serena/tools/workflow_tools.py +++ b/src/serena/tools/workflow_tools.py @@ -4,7 +4,7 @@ Tools supporting the general workflow of the agent import platform -from serena.tools import ReadMemoryTool, Tool, ToolMarkerDoesNotRequireActiveProject, WriteMemoryTool +from serena.tools import ReadMemoryTool, Tool, ToolMarkerDoesNotRequireActiveProject, ToolMarkerOptional, WriteMemoryTool class CheckOnboardingPerformedTool(Tool): @@ -72,3 +72,20 @@ class InitialInstructionsTool(Tool, ToolMarkerDoesNotRequireActiveProject): as it will critically inform you! """ return self.agent.create_system_prompt(session_id=session_id) + + +class SerenaInfoTool(Tool, ToolMarkerOptional, ToolMarkerDoesNotRequireActiveProject): + """ + Provides information about an advanced topic on demand, facilitating context-efficiency. + """ + + def apply(self, topic: str) -> str: + """ + Retrieves Serena-specific information + :param topic: the topic, which you must have been given explicitly + """ + match topic: + case "jet_brains_debug_repl": + return self.agent.prompt_factory.create_info_jet_brains_debug_repl() + case _: + raise ValueError("Invalid topic: " + topic) From 18e7e63070823c6c42bdbf4c0b8f3cb640e19df9 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 24 Apr 2026 16:20:42 +0200 Subject: [PATCH 33/34] Bump mcp from 1.26.0 to 1.27.0 #1367 --- CHANGELOG.md | 1 + pyproject.toml | 2 +- uv.lock | 8 ++++---- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a9c4006..3c3f1f13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ Status of the `main` branch. Changes prior to the next official version change w * Dependencies: - `pywebview`: Switch back to official release (new version 6.2) #1253 + - `mcp`: Update from `1.26.0` to `1.27.0` * Evaluations: - Added new evaluations for Junie Plugin with Opus 4.6 and GLM 5.1 in Claude Code. diff --git a/pyproject.toml b/pyproject.toml index 6df80045..837800e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ dependencies = [ "fortls==3.2.2", "overrides==7.7.0", "python-dotenv==1.2.2", - "mcp==1.26.0", + "mcp==1.27.0", "flask==3.1.3", # bumped from 3.1.1 for CVE fix (also fixes werkzeug alert) "sensai-utils==1.5.0", "pydantic==2.12.5", diff --git a/uv.lock b/uv.lock index b953940e..659b9257 100644 --- a/uv.lock +++ b/uv.lock @@ -1382,7 +1382,7 @@ wheels = [ [[package]] name = "mcp" -version = "1.26.0" +version = "1.27.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1400,9 +1400,9 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8b/eb/c0cfc62075dc6e1ec1c64d352ae09ac051d9334311ed226f1f425312848a/mcp-1.27.0.tar.gz", hash = "sha256:d3dc35a7eec0d458c1da4976a48f982097ddaab87e278c5511d5a4a56e852b83", size = 607509, upload-time = "2026-04-02T14:48:08.88Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" }, + { url = "https://files.pythonhosted.org/packages/9c/46/f6b4ad632c67ef35209a66127e4bddc95759649dd595f71f13fba11bdf9a/mcp-1.27.0-py3-none-any.whl", hash = "sha256:5ce1fa81614958e267b21fb2aa34e0aea8e2c6ede60d52aba45fd47246b4d741", size = 215967, upload-time = "2026-04-02T14:48:07.24Z" }, ] [[package]] @@ -2944,7 +2944,7 @@ requires-dist = [ { name = "joblib", specifier = "==1.5.1" }, { name = "jupyter-book", marker = "extra == 'dev'", specifier = "==1.0.4.post1" }, { name = "lsprotocol", specifier = "==2025.0.0" }, - { name = "mcp", specifier = "==1.26.0" }, + { name = "mcp", specifier = "==1.27.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = "==1.17.0" }, { name = "overrides", specifier = "==7.7.0" }, { name = "pathspec", specifier = "==0.12.1" }, From fc27a5204e702f7dd008fbb7e925b8ab4d20e800 Mon Sep 17 00:00:00 2001 From: Andrew Newell Date: Fri, 24 Apr 2026 11:16:37 -0400 Subject: [PATCH 34/34] Fix: 1397 Expert deadlock by sending didOpen(mix.exs) before readiness wait (#1405) * Fix: Expert deadlock by sending didOpen(mix.exs) before readiness wait Expert's build pipeline only starts after receiving a textDocument/didOpen notification. Without it, Serena waited 300 s for $/progress signals that Expert never emitted, causing a deadlock on every cold start. --- CHANGELOG.md | 1 + .../elixir_tools/elixir_tools.py | 26 +++++ test/solidlsp/elixir/test_elixir_startup.py | 104 ++++++++++++++++++ 3 files changed, 131 insertions(+) create mode 100644 test/solidlsp/elixir/test_elixir_startup.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c3f1f13..0265b3c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,7 @@ Status of the `main` branch. Changes prior to the next official version change w - Fix: `rename_symbol` for Vue files now correctly propagates edits to the TypeScript server, enabling cross-file renames in `.vue` files - Fix: Lean4 stale cache — empty document symbol responses (returned before `lake build` completes) are no longer persisted, preventing symbols from being permanently hidden #1356 - Add JSON language server support via `vscode-json-languageserver` (experimental) #1391 + - Fix: Elixir/Expert deadlock on startup — Expert's build pipeline requires a `textDocument/didOpen` notification to start; Serena now opens `mix.exs` immediately after `initialized` so Expert begins compiling instead of waiting indefinitely #1397 Dashboard: - Add configurable dashboard interface mode (new global configuration setting `web_dashboard_interface`): diff --git a/src/solidlsp/language_servers/elixir_tools/elixir_tools.py b/src/solidlsp/language_servers/elixir_tools/elixir_tools.py index 688da5e8..5ca86256 100644 --- a/src/solidlsp/language_servers/elixir_tools/elixir_tools.py +++ b/src/solidlsp/language_servers/elixir_tools/elixir_tools.py @@ -360,6 +360,28 @@ class ElixirTools(SolidLanguageServer): self.server.notify.initialized({}) + # Expert's build pipeline is triggered by a textDocument/didOpen notification. + # Without it Expert sits idle and never emits the $/progress signals we wait for, + # causing a deadlock. Opening mix.exs is the minimal trigger; we close it again + # once the server is ready so it does not linger in the open-file set. + mix_exs_path = os.path.join(self.repository_root_path, "mix.exs") + mix_exs_uri = pathlib.Path(mix_exs_path).as_uri() if os.path.exists(mix_exs_path) else None + + if mix_exs_uri is not None: + with open(mix_exs_path, encoding="utf-8") as f: + mix_exs_content = f.read() + self.server.notify.did_open_text_document( + { + "textDocument": { + "uri": mix_exs_uri, + "languageId": "elixir", + "version": 1, + "text": mix_exs_content, + } + } + ) + log.debug("Opened mix.exs to trigger Expert's build pipeline") + # Expert needs time to compile the project and build indexes on first run. # This can take 2-3+ minutes for mid-sized codebases. # After the first run, subsequent startups are much faster. @@ -370,3 +392,7 @@ class ElixirTools(SolidLanguageServer): else: log.warning(f"Expert did not signal readiness within {ready_timeout}s. Proceeding with requests anyway.") self.server_ready.set() # Mark as ready anyway to allow requests + + if mix_exs_uri is not None: + self.server.notify.did_close_text_document({"textDocument": {"uri": mix_exs_uri}}) + log.debug("Closed mix.exs after Expert is ready") diff --git a/test/solidlsp/elixir/test_elixir_startup.py b/test/solidlsp/elixir/test_elixir_startup.py new file mode 100644 index 00000000..49a84be7 --- /dev/null +++ b/test/solidlsp/elixir/test_elixir_startup.py @@ -0,0 +1,104 @@ +"""Unit tests for Elixir Expert language server startup behavior.""" + +import threading +from unittest.mock import MagicMock + +from solidlsp.language_servers.elixir_tools.elixir_tools import ElixirTools + + +def _make_elixir_tools(tmp_path): + language_server = object.__new__(ElixirTools) + language_server.repository_root_path = str(tmp_path) + language_server.server_ready = threading.Event() + language_server.request_id = 0 + return language_server + + +def _make_mock_server(): + server = MagicMock() + server.send.initialize.return_value = {"capabilities": {"textDocumentSync": {}}} + return server + + +class TestElixirToolsStartup: + """Tests for ElixirTools._start_server() startup behavior — the Expert deadlock fix.""" + + def test_sends_did_open_for_mix_exs_after_initialized(self, tmp_path): + """Expert blocks waiting for a didOpen notification to trigger its build pipeline.""" + (tmp_path / "mix.exs").write_text("defmodule App.MixProject do\n use Mix.Project\nend\n", encoding="utf-8") + + language_server = _make_elixir_tools(tmp_path) + server = _make_mock_server() + language_server.server = server + language_server.server_ready.set() + + language_server._start_server() + + calls = server.notify.did_open_text_document.call_args_list + assert len(calls) == 1, f"Expected one didOpen notification for mix.exs, got {len(calls)}" + text_doc = calls[0][0][0]["textDocument"] + assert text_doc["uri"].endswith("mix.exs"), f"Expected mix.exs URI, got {text_doc['uri']}" + assert text_doc["languageId"] == "elixir" + + def test_did_open_contains_mix_exs_content(self, tmp_path): + """The didOpen notification must include the file content so Expert can parse it.""" + content = "defmodule App.MixProject do\n use Mix.Project\nend\n" + (tmp_path / "mix.exs").write_text(content, encoding="utf-8") + + language_server = _make_elixir_tools(tmp_path) + server = _make_mock_server() + language_server.server = server + language_server.server_ready.set() + + language_server._start_server() + + calls = server.notify.did_open_text_document.call_args_list + assert len(calls) == 1 + text_doc = calls[0][0][0]["textDocument"] + assert text_doc["text"] == content + + def test_closes_mix_exs_after_server_ready(self, tmp_path): + """mix.exs is closed after Expert signals readiness so it does not linger open.""" + (tmp_path / "mix.exs").write_text("defmodule App.MixProject do\n use Mix.Project\nend\n", encoding="utf-8") + + language_server = _make_elixir_tools(tmp_path) + server = _make_mock_server() + language_server.server = server + language_server.server_ready.set() + + language_server._start_server() + + close_calls = server.notify.did_close_text_document.call_args_list + assert len(close_calls) == 1, f"Expected one didClose for mix.exs, got {len(close_calls)}" + text_doc = close_calls[0][0][0]["textDocument"] + assert text_doc["uri"].endswith("mix.exs"), f"Expected mix.exs URI, got {text_doc['uri']}" + + def test_startup_event_order_initialized_then_didopen_then_didclose(self, tmp_path): + """Initialized → didOpen(mix.exs) → [ready] → didClose(mix.exs) is the required sequence.""" + (tmp_path / "mix.exs").write_text("defmodule App.MixProject do\n use Mix.Project\nend\n", encoding="utf-8") + + events: list[str] = [] + + language_server = _make_elixir_tools(tmp_path) + server = _make_mock_server() + language_server.server = server + language_server.server_ready.set() + + server.notify.initialized.side_effect = lambda *_: events.append("initialized") + server.notify.did_open_text_document.side_effect = lambda *_: events.append("didOpen") + server.notify.did_close_text_document.side_effect = lambda *_: events.append("didClose") + + language_server._start_server() + + assert events == ["initialized", "didOpen", "didClose"], f"Unexpected startup event order: {events}" + + def test_no_crash_when_mix_exs_missing(self, tmp_path): + """_start_server must not crash if mix.exs is absent (non-standard project layout).""" + language_server = _make_elixir_tools(tmp_path) + server = _make_mock_server() + language_server.server = server + language_server.server_ready.set() + + language_server._start_server() + + server.notify.did_open_text_document.assert_not_called()