From a068cb55636a3775fdbc4afeafaa2ce29e999988 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Wed, 1 Apr 2026 23:46:53 +0200 Subject: [PATCH 01/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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 1beb2523f7fb634ddbf2bd0ac8da176c681bbfaa Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 23 Apr 2026 01:48:44 +0200 Subject: [PATCH 13/16] 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 14/16] 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 15/16] 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 16/16] 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)