diff --git a/CHANGELOG.md b/CHANGELOG.md
index 836f2a23..aea6203e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -25,12 +25,20 @@ Status of the `main` branch. Changes prior to the next official version change w
- Project server:
- `query_project`: Support use of project root instead of project name #1388
- `list_queryable_projects`: Return both project names and project roots
+ - Fix: `search_for_pattern` tool returned 1-based line numbers (in contrast to all other tools); cause: implementation of `text_utils.search_text`
+ - Serena's system prompt (a.k.a. the 'Serena Instructions Manual') is now provided lazily.
+ At MCP connection time, only a one-sentence bootstrap prompt is provided.
+ The `initial_instructions` tool provides the full prompt on demand, keeping the initial context lean.
+ - Add `serena_info` tool for on-demand retrieval of usage information
* JetBrains:
- - `Move` and `SafeDelete` tools: transform empty string to None (counteracts client errors)
+ - Add `debug` tool: The agent can set breakpoints, inspect variables, evaluate expressions and control execution flow
+ by directly interacting with the IDE's debugger, using a REPL-style interface for maximum flexibility.
+ - `move` and `safe_delete` tools: transform empty string to None (counteracts client errors)
* Dependencies:
- `pywebview`: Switch back to official release (new version 6.2) #1253
+ - `mcp`: Update from `1.26.0` to `1.27.0`
* Evaluations:
- Added new evaluations for Junie Plugin with Opus 4.6 and GLM 5.1 in Claude Code.
@@ -39,7 +47,13 @@ Status of the `main` branch. Changes prior to the next official version change w
- Fix: clangd capability checks now tolerate valid initialize response shape differences and invalidate cached C++ document symbols when clangd/compile commands context changes #1359
- Fix: `rename_symbol` for Vue files now correctly propagates edits to the TypeScript server, enabling cross-file renames in `.vue` files
- Fix: Lean4 stale cache — empty document symbol responses (returned before `lake build` completes) are no longer persisted, preventing symbols from being permanently hidden #1356
+ - Add JSON language server support via `vscode-json-languageserver` (experimental) #1391
+ - Fix: Elixir/Expert deadlock on startup — Expert's build pipeline requires a `textDocument/didOpen` notification to start; Serena now opens `mix.exs` immediately after `initialized` so Expert begins compiling instead of waiting indefinitely #1397
+Dashboard:
+ - Add configurable dashboard interface mode (new global configuration setting `web_dashboard_interface`):
+ 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/README.md b/README.md
index d26bdad8..0c819fb4 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
-* Serena provides essential **semantic code retrieval, editing and refactoring tools** that are akin to an IDE's capabilities,
+* Serena provides essential **semantic code retrieval, editing, refactoring and debugging tools** that are akin to an IDE's capabilities,
operating at the symbol level and exploiting relational structure.
* It integrates with any client/LLM via the model context protocol (**MCP**).
@@ -110,7 +110,7 @@ Serena incorporates a powerful abstraction layer for the integration of language
The underlying language servers are typically open-source projects or at least freely available for use.
When using Serena's language server backend, we provide **support for over 40 programming languages**, including
-AL, Ansible, Bash, C#, C/C++, Clojure, Crystal, Dart, Elixir, Elm, Erlang, Fortran, F#, GLSL, Go, Groovy, Haskell, Haxe, HLSL, Java, JavaScript, Julia, Kotlin, Lean 4, Lua, Luau, Markdown, MATLAB, mSL, Nix, OCaml, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, Solidity, Swift, TOML, TypeScript, WGSL, YAML, and Zig.
+AL, Ansible, Bash, C#, C/C++, Clojure, Crystal, Dart, Elixir, Elm, Erlang, Fortran, F#, GLSL, Go, Groovy, Haskell, Haxe, HLSL, Java, JavaScript, JSON, Julia, Kotlin, Lean 4, Lua, Luau, Markdown, MATLAB, mSL, Nix, OCaml, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, Solidity, Swift, TOML, TypeScript, WGSL, YAML, and Zig.
### The Serena JetBrains Plugin
@@ -171,6 +171,12 @@ Serena's symbolic editing tools are less error-prone and much more token-efficie
| insert before symbol | yes | yes |
| safe delete | yes | yes |
+### Interactive Debugging
+
+Exclusive to the JetBrains plugin, Serena supports a highly general debugging tool,
+which allows an agent to set breakpoints, inspect variables, evaluate expressions and control execution flow
+via a persistent REPL-style interface.
+
### Basic Features
Beyond its semantic capabilities, Serena includes a set of basic utilities for completeness.
diff --git a/docs/01-about/020_programming-languages.md b/docs/01-about/020_programming-languages.md
index 999782ee..dff0a228 100644
--- a/docs/01-about/020_programming-languages.md
+++ b/docs/01-about/020_programming-languages.md
@@ -115,6 +115,8 @@ Some languages require additional installations or setup steps, as noted.
* **Vue**
(3.x with TypeScript; requires Node.js v18+ and npm; supports .vue Single File Components with monorepo detection)
* **YAML**
+* **JSON**
+ (experimental; must be explicitly added to the languages list; requires Node.js and npm)
* **Zig**
(requires installation of ZLS - Zig Language Server)
diff --git a/docs/02-usage/025_jetbrains_plugin.md b/docs/02-usage/025_jetbrains_plugin.md
index 1995ca34..4eb08688 100644
--- a/docs/02-usage/025_jetbrains_plugin.md
+++ b/docs/02-usage/025_jetbrains_plugin.md
@@ -43,6 +43,8 @@ There are multiple features that are only available when using the JetBrains plu
* **Enhanced retrieval & refactoring capabilities**: The plugin adds additional [tools](../01-about/035_tools) (e.g. type
hierarchy retrieval, move, find declaration, inline symbol, etc.)
and transforms the underlying mechanisms of shared tools to build upon the IDE's capabilities.
+* **Interactive debugging**: The agent can set breakpoints, inspect variables, evaluate expressions and control execution flow
+ by directly interacting with the IDE's debugger, using a REPL-style interface for maximum flexibility.
* **Improved multi-agent support**: A single IDE instance naturally serves arbitrarily many agent sessions without requiring additional resources.
* **Enhanced performance**: Faster tool execution thanks to optimized IDE integration.
* **Multi-language excellence** and **framework support**: First-class support for polyglot projects with multiple languages.
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/pyproject.toml b/pyproject.toml
index e09bf469..837800e8 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -21,8 +21,8 @@ dependencies = [
"pyright==1.1.403",
"fortls==3.2.2",
"overrides==7.7.0",
- "python-dotenv==1.2.1",
- "mcp==1.26.0",
+ "python-dotenv==1.2.2",
+ "mcp==1.27.0",
"flask==3.1.3", # bumped from 3.1.1 for CVE fix (also fixes werkzeug alert)
"sensai-utils==1.5.0",
"pydantic==2.12.5",
@@ -88,13 +88,11 @@ dev = [
"sphinx_book_theme==1.1.4",
"sphinx-toolbox==3.7.0",
"jupyter-book==1.0.4.post1",
- "nbsphinx==0.9.7",
"pyinstrument==5.1.1",
"pytest-timeout==2.4.0",
# Transitive dev deps pinned for security (dependabot alerts).
# Exact pins because uvx installs from git, ignoring the lock file.
"tornado==6.5.5",
- "nbconvert==7.17.0",
"wheel==0.46.3",
"pyasn1==0.6.3",
"PyJWT==2.12.0",
@@ -338,6 +336,7 @@ markers = [
"haskell: Haskell language server tests",
"haxe: Haxe language server tests",
"yaml: language server running for YAML",
+ "json: language server running for JSON (uses vscode-json-languageserver)",
"powershell: language server running for PowerShell",
"pascal: language server running for Pascal (Free Pascal/Lazarus)",
"cpp: language server running for C/C++",
diff --git a/src/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 9331b26d..2daf0b4e 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).
@@ -754,8 +879,18 @@ class SerenaAgent:
template = JinjaTemplate(prompt_template)
return template.render(available_tools=self._exposed_tools.tool_names, available_markers=self._exposed_tools.tool_marker_names)
+ def create_connection_prompt(self) -> str:
+ """
+ Returns the bootstrap prompt to be sent at MCP connection time.
+
+ :return: the prompt
+ """
+ return self.prompt_factory.create_connection_prompt()
+
def create_system_prompt(self, session_id: str = "global") -> str:
"""
+ Returns the 'Serena Instructions Manual', i.e. Serena's system prompt.
+
:param session_id: the client session ID for the case where this is run from a tool; "global" for the connection time case
:return: the prompt
"""
@@ -788,7 +923,6 @@ class SerenaAgent:
if self._active_project is not None and not self._project_prompt_status.is_project_activation_message_already_provided(session_id):
system_prompt += "\n\n" + self.get_project_activation_message(session_id)
- log.info("System prompt:\n%s", system_prompt)
return system_prompt
def get_project_activation_message(self, session_id: str) -> str:
@@ -971,6 +1105,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(
@@ -1114,10 +1252,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..9a35806c 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,45 @@ 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
+ try:
+ subprocess.Popen(
+ [sys.executable, "-c", f"import webbrowser; webbrowser.open({url!r})"],
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ start_new_session=False,
+ )
+ except Exception as e:
+ # Subprocess creation can fail in rare cases (e.g. on some Linux systems; possibly subprocess/glibc bug)
+ # See #1363
+ log.error("Failed to open URL (%s) in subprocess; %s", url, e)
+ else:
+ import webbrowser
+
+ 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 +835,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 +869,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/generated/generated_prompt_factory.py b/src/serena/generated/generated_prompt_factory.py
index 9129318c..43f866d7 100644
--- a/src/serena/generated/generated_prompt_factory.py
+++ b/src/serena/generated/generated_prompt_factory.py
@@ -14,23 +14,14 @@ class PromptFactory(PromptFactoryBase):
A class for retrieving and rendering prompt templates and prompt lists.
"""
+ def create_info_jet_brains_debug_repl(self) -> str:
+ return self._render_prompt("info_jet_brains_debug_repl", locals())
+
def create_onboarding_prompt(self, *, system: Any) -> str:
return self._render_prompt("onboarding_prompt", locals())
- def create_think_about_collected_information(self) -> str:
- return self._render_prompt("think_about_collected_information", locals())
-
- def create_think_about_task_adherence(self) -> str:
- return self._render_prompt("think_about_task_adherence", locals())
-
- def create_think_about_whether_you_are_done(self) -> str:
- return self._render_prompt("think_about_whether_you_are_done", locals())
-
- def create_summarize_changes(self) -> str:
- return self._render_prompt("summarize_changes", locals())
-
- def create_prepare_for_new_conversation(self) -> str:
- return self._render_prompt("prepare_for_new_conversation", locals())
+ def create_connection_prompt(self) -> str:
+ return self._render_prompt("connection_prompt", locals())
def create_system_prompt(
self,
diff --git a/src/serena/jetbrains/jetbrains_plugin_client.py b/src/serena/jetbrains/jetbrains_plugin_client.py
index 810618ba..263bc8a9 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)
@@ -632,6 +633,34 @@ class JetBrainsPluginClient(ToStringMixin):
self._postprocess_symbol_collection_response(symbol_collection)
return symbol_collection
+ def debug_eval(self, repl_key: str, expression: str) -> dict[str, Any]:
+ """
+ Evaluates a Groovy expression in the persistent debug REPL.
+
+ :param repl_key: the session key identifying the REPL instance
+ :param expression: the Groovy expression to evaluate
+ :return: the response containing REPL key and result
+ """
+ self._require_version_at_least(2023, 2, 16)
+ request_data = {
+ "replKey": repl_key,
+ "expression": expression,
+ }
+ return self._make_request("POST", "/debugReplEval", request_data)
+
+ def debug_close(self, repl_key: str) -> dict[str, Any]:
+ """
+ Closes the debug REPL for the given session key, clearing all state.
+
+ :param repl_key: the key identifying the REPL instance to close
+ :return: the status response
+ """
+ self._require_version_at_least(2023, 2, 16)
+ request_data = {
+ "replKey": repl_key,
+ }
+ return self._make_request("POST", "/debugReplClose", request_data)
+
def close(self) -> None:
self._session.close()
diff --git a/src/serena/mcp.py b/src/serena/mcp.py
index a8252846..13b9a00c 100644
--- a/src/serena/mcp.py
+++ b/src/serena/mcp.py
@@ -332,6 +332,7 @@ class SerenaMCPFactory:
# retain only FASTMCP_ prefix for already set environment variables.
Settings.model_config = SettingsConfigDict(env_prefix="FASTMCP_")
instructions = self._get_initial_instructions()
+ log.info("MCP server initial instructions:\n%s", instructions)
mcp = FastMCP(
name="Serena",
lifespan=self.server_lifespan,
@@ -357,4 +358,4 @@ class SerenaMCPFactory:
def _get_initial_instructions(self) -> str:
assert self.agent is not None
- return self.agent.create_system_prompt()
+ return self.agent.create_connection_prompt()
diff --git a/src/serena/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/config/contexts/claude-code.yml b/src/serena/resources/config/contexts/claude-code.yml
index b6d632a7..fe63878b 100644
--- a/src/serena/resources/config/contexts/claude-code.yml
+++ b/src/serena/resources/config/contexts/claude-code.yml
@@ -7,16 +7,15 @@ prompt: |
**IMPORTANT**: If you haven't seen Serena's tools yet, you should use the tool search tool to load all of them
right now.
For exploring and reading code in a token-efficient manner, use Serena's code-centric overview and symbolic search tools.
- For edits, where it makes sense, use Serena's editing tools since they are more token efficient.
- Use your own editing tools only if Serena's editing tools would be suboptimal.
- **IMPORTANT**: Never use a tool before reading its schema via the tool-search tool!
+ For edits, prefer Serena's editing tools since they are more token efficient.
+ Especially when editing code you have read through Serena, ALWAYS use Serena's tools for editing, because your built-in tools will deny such edits (they will assume you haven't read the content).
+ **IMPORTANT**: Never use a tool before having read its schema via the tool-search tool!
excluded_tools:
- create_text_file
- read_file
- execute_shell_command
- - replace_content
- find_file
- list_dir
- search_for_pattern
diff --git a/src/serena/resources/config/internal_modes/jetbrains.yml b/src/serena/resources/config/internal_modes/jetbrains.yml
index a08b189f..34baf6ce 100644
--- a/src/serena/resources/config/internal_modes/jetbrains.yml
+++ b/src/serena/resources/config/internal_modes/jetbrains.yml
@@ -23,3 +23,5 @@ included_optional_tools:
- jet_brains_type_hierarchy
- jet_brains_rename
- jet_brains_safe_delete
+ - jet_brains_debug
+ - serena_info
diff --git a/src/serena/resources/config/prompt_templates/info_prompts.yml b/src/serena/resources/config/prompt_templates/info_prompts.yml
new file mode 100644
index 00000000..4c896429
--- /dev/null
+++ b/src/serena/resources/config/prompt_templates/info_prompts.yml
@@ -0,0 +1,64 @@
+# Defines prompts the SerenaInfoTool
+prompts:
+ info_jet_brains_debug_repl: |
+ Note: The debugger uses 0-based line numbers like other Serena tools (while humans typically communicate 1-based ones).
+ You rely on the user to provide suitable run configurations for you to work with.
+
+ State (objects, variables assigned) persists across calls within the same repl_key.
+
+ Pre-bound variables: ``debug`` (debugger interface)
+
+ Run configurations:
+ debug.listRunConfigs()
+ session = debug.startDebug("MyTestRun") # returns a DebugSession
+ debug.startRun("MyApp") # non-debug run, fire-and-forget
+
+ Session discovery:
+ debug.sessions()
+ session = debug.currentSession()
+ session = debug.session(id)
+
+ Breakpoints (project-global, set before or during debugging):
+ debug.addBreakpoint(file, line)
+ debug.addBreakpoint(file, line, condition)
+ debug.addLogpoint(file, line, logExpression)
+ debug.listBreakpoints()
+ debug.removeBreakpointAt(file, line)
+
+ Execution control (on a DebugSession, e.g. ``session``)::
+ session.stepOver() | .stepInto() | .stepOut()
+ session.resume() | .pause() | .stop()
+ session.runToLine(file, line)
+
+ Wait for pause (blocks until the session pauses or times out):
+ session.waitForPause(timeoutSeconds)
+ session.stepOverAndWait(timeoutSeconds) # step + wait in one call
+ session.resumeAndWait(timeoutSeconds)
+
+ Inspection (when paused):
+ session.status() # location + source + variable values in one call
+ session.variables() # overview of locals/args (values may be truncated)
+ session.variable(name) # single variable (VariableInfo instance)
+ session.frames() | .frame() # stack trace | current frame
+ session.sourceContext(lines) # source around current line
+ session.threads()
+
+ VariableInfo members: name, value (String as presented by the debugger), type
+
+ Evaluation & modification:
+ session.eval("expr") # evaluate in current frame
+ session.setVar("x", "42") # set variable via assignment
+
+ Example sequence of expressions one might execute in a REPL:
+ debug.addBreakpoint("src/main/java/Foo.java", 42)
+ session = debug.startDebug("MyTest")
+ session.waitForPause(30)
+ session.status()
+
+ You can pass multiple statements at once; the result of the final expression is returned:
+ session = debug.startDebug("MyTest"); session.waitForPause(30); session.status()
+
+ Control flow statements are also possible:
+ while (session.variable("count").value.equals("0")) { session.stepOverAndWait(5) }
+
+ When done, close the REPL by passing an empty expression.
diff --git a/src/serena/resources/config/prompt_templates/system_prompt.yml b/src/serena/resources/config/prompt_templates/system_prompt.yml
index debefcd2..426ba0be 100644
--- a/src/serena/resources/config/prompt_templates/system_prompt.yml
+++ b/src/serena/resources/config/prompt_templates/system_prompt.yml
@@ -2,6 +2,8 @@
# in which case this prompt will be given as a regular message on the call of a simple tool which the agent
# is encouraged (via the tool description) to call at the beginning of the conversation.
prompts:
+ connection_prompt: |
+ CRITICAL: Before starting to work on a coding task, call the `initial_instructions` tool to read the 'Serena Instructions Manual'.
system_prompt: |
You have access to semantic coding tools upon which you rely heavily for all your work.
You operate in a resource-efficient and intelligent manner, always keeping in mind to not read or generate
@@ -41,7 +43,9 @@ prompts:
The following global (not project-specific) memories are available to you: {{ global_memories_list }}
{%- endif -%}
{%- endif %}
-
+
+ Line numbers returned by Serena's tools are 0-based!
+
The context and modes of operation are described below. These determine how to interact with your user
and which kinds of interactions are expected of you.
diff --git a/src/serena/resources/dashboard/dashboard.js b/src/serena/resources/dashboard/dashboard.js
index df53868e..e48ff3c2 100644
--- a/src/serena/resources/dashboard/dashboard.js
+++ b/src/serena/resources/dashboard/dashboard.js
@@ -485,32 +485,9 @@ class Dashboard {
// Initialize the application
this.loadToolNames().then(function () {
- // Start on overview page
self.loadNews();
- self.loadConfigOverview();
- self.startConfigPolling();
- self.startExecutionsPolling();
- });
- // Initialize heartbeat interval
- setInterval(this.heartbeat.bind(this), 250);
- }
-
- heartbeat() {
- let self = this;
- $.ajax({
- url: '/heartbeat',
- type: 'GET',
- success: function (response) {
- self.heartbeatFailureCount = 0;
- },
- error: function (xhr, status, error) {
- self.heartbeatFailureCount++;
- console.error('Heartbeat failure; count = ', self.heartbeatFailureCount);
- if (self.heartbeatFailureCount >= 1) {
- console.log('Server appears to be down, closing tab');
- window.close();
- }
- },
+ // start on overview page
+ self.navigateToPage("overview");
});
}
@@ -541,7 +518,6 @@ class Dashboard {
// Start appropriate polling for the page
if (page === 'overview') {
this.loadNews();
- this.loadConfigOverview();
this.startConfigPolling();
this.startExecutionsPolling();
} else if (page === 'logs') {
@@ -599,7 +575,8 @@ class Dashboard {
} else {
console.log('Config unchanged, skipping display update');
}
- }, error: function (xhr, status, error) {
+ },
+ error: function (xhr, status, error) {
console.error('Error loading config overview:', error);
self.$configDisplay.html('