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 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() diff --git a/src/serena/agent.py b/src/serena/agent.py index 295a4add..e3800b97 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -7,12 +7,13 @@ import multiprocessing import os import platform import signal -import subprocess -import sys +import threading from collections import defaultdict from collections.abc import Callable, Iterator, Sequence from contextlib import contextmanager from dataclasses import dataclass +from datetime import datetime +from enum import Enum from logging import Logger from typing import TYPE_CHECKING, Optional, TypeVar @@ -36,7 +37,7 @@ from serena.config.serena_config import ( SerenaPaths, ToolInclusionDefinition, ) -from serena.dashboard import SerenaDashboardAPI, 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 @@ -321,6 +322,178 @@ class ProjectPromptProvisionStatus: return self._get_session_status(session_id).project_activation_message_provided +class DashboardManager: + class Mode(Enum): + BROWSER = "browser" + """ + Open the dashboard in the default browser; supported on all platforms. + """ + WEBVIEW = "app" + """ + 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" + """ + 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.WEBVIEW + case "Darwin": + # TODO: Switch to TRAY_MANAGER once support is tested + return cls.BROWSER + case _: + return cls.BROWSER + + 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 = mode + 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": + dashboard_host = "localhost" + self.url = f"http://{dashboard_host}:{port}/dashboard/index.html" + + # handle startup + match self._mode: + 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) + 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(): + log.info("Not opening the Serena dashboard because no usable display was detected.") + else: + self.open_dashboard_in_browser() + + def open_dashboard_in_browser(self) -> None: + 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: + """ + 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, use_subprocess=True) + + 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 + """ + with LogTime("Dashboard tray manager initialisation"): + with self._tray_manager_lock: + # 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: + with self._tray_manager_lock: + 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: + 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: def __init__( self, @@ -344,8 +517,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._project_prompt_status = ProjectPromptProvisionStatus() self._mode_overrides = modes self.version = serena_version() @@ -365,9 +539,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: @@ -445,7 +616,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 @@ -475,16 +645,17 @@ 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) - 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._dashboard_manager = DashboardManager( + 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) 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() @@ -652,44 +823,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) + if self._dashboard_manager is None: + return None + return self._dashboard_manager.url def open_dashboard(self) -> bool: """ @@ -697,27 +833,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). @@ -983,6 +1108,10 @@ class SerenaAgent: if self._project_activation_callback is not None: self._project_activation_callback() + # 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 def activate_project_from_path_or_name( @@ -1126,10 +1255,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 self._dashboard_manager: + self._dashboard_manager.shutdown() + self._dashboard_manager = None def shutdown(self) -> None: """ diff --git a/src/serena/cli.py b/src/serena/cli.py index eb8e91fc..5e87fa2e 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -479,11 +479,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/config/serena_config.py b/src/serena/config/serena_config.py index 457e0705..5c8e4e10 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -692,6 +692,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/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 569b17a2..d2f5e1fa 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -1,22 +1,29 @@ import json +import multiprocessing import os import socket +import subprocess import sys import threading +import time import urllib.error import urllib.request +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 psutil from flask import Flask, Response, redirect, request, send_from_directory +from PIL import Image from pydantic import BaseModel from sensai.util import logging from sensai.util.pickle import dump_pickle, load_pickle 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 from serena.util.pywebview import WebViewWithTray @@ -180,6 +187,8 @@ class ReadNews: class SerenaDashboardAPI: + BASE_PORT = SerenaPorts.DASHBOARD_API_BASE_PORT + log = logging.getLogger(__qualname__) def __init__( @@ -773,16 +782,40 @@ 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() return thread, port +def open_url_in_browser(url: str, use_subprocess: bool = False) -> None: + """ + 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 + """ + 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): """ - Minimal pywebview wrapper with optional system tray. + Minimal pywebview wrapper that opens a dashboard in a native window with optional system tray. """ DEBUG = False @@ -797,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. @@ -822,21 +864,426 @@ class SerenaDashboardViewer(WebViewWithTray): @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 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() + + +@dataclass +class TrayManagedInstance: + """A registered Serena dashboard instance managed by the tray manager.""" + + 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""" + + project: str | None + """the name of the active project, or None if no project is activated""" + + started_at: str + """ISO 8601 timestamp of when the agent instance was started""" + + +class SerenaDashboardTrayManager: + """ + Singleton process managing a system tray icon for all Serena dashboard instances. + + 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. + + The manager is started as a detached process by the first Serena agent that + needs it and terminates automatically when no dashboard instances remain. + """ + + DEBUG = False + + PORT = SerenaPorts.TRAY_MANAGER_PORT + + HOST = "127.0.0.1" + """listen address (local only)""" + + ALIVE_CHECK_INTERVAL_SECONDS = 3 + """interval in seconds between alive checks of registered instances""" + + 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 + + def _setup_routes(self) -> None: + @self._app.route("/health", methods=["GET"]) + def health() -> dict[str, str]: + return {"status": "alive"} + + @self._app.route("/register", methods=["POST"]) + def register() -> dict[str, str]: + 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"], + ) + log.info("Registering instance on port %d (project=%s)", instance.port, instance.project) + with self._lock: + self._instances[instance.port] = instance + self._update_menu() + + # open a viewer immediately if requested + if data.get("open_viewer", False): + self._open_dashboard(instance) + + 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 + self._update_menu() + 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) + 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. + + 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 + + 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_dashboard(instance) + + return _callback + + items.append(Item(label, _make_callback(inst), default=is_single)) + + return tuple(items) + + 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: + """Process target for running a dashboard viewer window.""" + SerenaDashboardViewer(url, tray=False, start_minimized=False).run() + + def _alive_check_loop(self) -> None: + """Periodically check whether registered instances are still reachable. + + 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) + + try: + dead_ports: list[int] = [] + + if self._alive_check_use_pid: + with self._lock: + ports_and_processes = [(port, inst.parent_process) for port, inst in self._instances.items()] + + 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: + 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.""" + 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 + icon_filename = "serena-icon-tray-mac.png" if sys.platform == "darwin" else "serena-icon-48.png" + icon_img = Image.open(dashboard_path / icon_filename) + + # 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": + from AppKit import NSApplication + + kwargs["darwin_nsapplication"] = NSApplication.sharedApplication() + + self._tray_icon = pystray.Icon( + "serena_tray_manager", + icon_img, + "Serena", + menu=pystray.Menu(self._build_menu_items), + **kwargs, + ) + + # 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 + # TODO: Enable all platforms for the time being for users to try out (only Windows tested) + return True + + @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, + "pid": os.getpid(), + "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) diff --git a/src/serena/jetbrains/jetbrains_plugin_client.py b/src/serena/jetbrains/jetbrains_plugin_client.py index 810618ba..d65925ea 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 a227dc3b..bd6c7a79 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 diff --git a/src/serena/resources/serena_config.template.yml b/src/serena/resources/serena_config.template.yml index a823c743..18974358 100644 --- a/src/serena/resources/serena_config.template.yml +++ b/src/serena/resources/serena_config.template.yml @@ -46,6 +46,21 @@ 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-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, 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 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). web_dashboard_listen_address: 127.0.0.1