Merge pull request #1380 from oraios/dashboard-manager

Introduce DashboardManager with three interface modes for the dashboard
This commit is contained in:
Dr. Dominik Jain authored and GitHub committed 2026-04-23 02:23:08 +02:00
commit d5724c4bd5
11 files changed
+710 -93

No files matched your search

+2
View File
@@ -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)
+10 -5
View File
@@ -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
+12
View File
@@ -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()
+199 -71
View File
@@ -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:
"""
+2 -3
View File
@@ -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()
+1
View File
@@ -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
+7
View File
@@ -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
+458 -11
View File
@@ -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)
@@ -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)
+2 -2
View File
@@ -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
@@ -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