From a068cb55636a3775fdbc4afeafaa2ce29e999988 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Wed, 1 Apr 2026 23:46:53 +0200 Subject: [PATCH] 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)