Merge pull request #1447 from oraios/hook_extension

Hook extension
This commit is contained in:
Michael Panchenko authored and GitHub committed 2026-05-05 17:35:39 +02:00
commit 626e370a89
8 files changed
+487 -129

No files matched your search

@@ -97,7 +97,7 @@ class Language(str, Enum):
match self:
# Existing cases...
case self.NEW_LANGUAGE:
return FilenameMatcher("*.newlang", "*.nl") # File extensions
return FilenameMatcher(".newlang", ".nl") # File extensions
```
### 2.2 Update Language Server Factory
+16 -6
View File
@@ -87,12 +87,13 @@ encoding: utf-8
# Set this to a list of mode names to always include the respective modes for this project.
base_modes:
# list of mode names that are to be activated by default.
# list of mode names that are to be activated by default, overriding the setting in the global configuration.
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply
# for this project.
# This setting can, in turn, be overridden by CLI parameters (--mode).
# Set this to [] to not use the default modes defined in the global config for this project.
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
default_modes:
@@ -137,9 +138,18 @@ ignored_memory_patterns: []
# No documentation on options means no options are available.
ls_specific_settings: {}
# list of mode names to be activated additionally for this project.
# list of mode names to be activated additionally for this project, e.g. ["query-projects"]
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
# Otherwise, this setting overrides the global configuration.
# Set this to a list of mode names to always include the respective modes for this project.
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
added_modes:
# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos).
# Paths can be absolute or relative to the project root.
# Each folder is registered as an LSP workspace folder, enabling language servers to discover
# symbols and references across package boundaries.
# Currently supported for: TypeScript.
# Example:
# additional_workspace_folders:
# - ../sibling-package
# - ../shared-lib
additional_workspace_folders: []
+8 -6
View File
@@ -2,23 +2,19 @@
Status of the `main` branch. Changes prior to the next official version change will appear here.
* Hooks:
- `serena-hooks auto-approve` now also emits an `allow` decision when Claude Code reports
`permission_mode == "auto"`, in addition to the existing `acceptEdits` behavior. #1386
* General:
- Breaking change in mode definitions: Projects (project.yml) can no longer override `base_modes`.
Instead, they can define `added_modes` to add modes on top of base and default modes.
See updated [documentation on modes](https://oraios.github.io/serena/02-usage/050_configuration.html#modes).
- Serena's default configuration now uses `interactive` and `editing` as `base_modes` instead of as `default_modes`.
- Add cross-package reference support via `additional_workspace_folders` setting (currently implemented for TypeScript).
* JetBrains:
- Add new tools:
- `jet_brains_list_inspections`: Lists available IDE inspections (akin to diagnostics), optionally filtered by language or group
- `jet_brains_run_inspections`: Runs IDE inspections on a file and returns the results
* LSP Tools:
* LSP Backend:
- Add cross-package reference support via `additional_workspace_folders` setting (currently implemented for TypeScript).
- Add new tools:
- `find_declaration`: Finds the declaration/definition of a symbol
- `find_implementations`: Finds the implementations of an interface or abstract method
@@ -33,6 +29,12 @@ Status of the `main` branch. Changes prior to the next official version change w
- Add **HTML** (experimental) using `vscode-html-language-server` from the `vscode-langservers-extracted` npm package. Provides in-file element/id symbols via documentSymbol; cross-file references are not meaningful for HTML. Also used as a companion server by the Angular LS for plain HTML documentSymbol support.
- Add **SCSS / Sass / CSS** (experimental) using [some-sass-language-server](https://github.com/wkillerud/some-sass). Handles `.scss`, `.sass`, and `.css` through one server, with full `@use`/`@forward` workspace-wide go-to-definition and find-references for variables, mixins, and functions across Sass files. The `.css` path uses the same `vscode-css-languageservice` engine that powers the standalone CSS LS; CSS feature toggles default off upstream and are flipped on at startup so symbols, hover, completion, and syntax-level diagnostics work for plain CSS as well.
* Hooks:
- `serena-hooks auto-approve` now also emits an `allow` decision when Claude Code reports
`permission_mode == "auto"`, in addition to the existing `acceptEdits` behavior. #1386
- Extension: heuristics for parsing commands and firing a hook on too many greps or reads. Important for clients that, unlike claude code, don't have dedicated grep/read tools.
- Read hook now only fires on reads of code files (using heuristics to parse the read command string)
# v1.2.0 (2026-04-27)
* General:
+5 -2
View File
@@ -145,11 +145,14 @@ without reading entire files.
| find referencing symbols | yes | yes |
| search in project dependencies | -- | yes |
| type hierarchy | -- | yes |
| find declaration | yes | yes |
| find implementations | yes | yes |
| find declaration | yes* | yes |
| find implementations | yes** | yes |
| query external projects | yes | yes |
| diagnostics/inspections | yes | yes |
*: Will generally not work for declarations in external dependencies. <br>
**: Only available for some languages, limited by the language server functionality.
### Refactoring
Without precise refactoring tools, agents are forced to resort to unreliable and expensive search and replace operations.
+61 -3
View File
@@ -318,9 +318,67 @@ The Codex app does not start a session in the project's directory, so when using
asking Codex to "Activate the current dir as project using serena" at the start of each session (though Codex might
do this automatically).
**Hooks.** The Codex hook system is less refined than of VSCode or Claude Code, but Codex also displays less drift
and is better in using Serena's tools out of the box. If you use the Codex app, you can consider setting up the
SessionStart hook (see above) to remind the agent to activate the project directory.
**Hooks.**
Codex supports lifecycle hooks; see the
[Codex hooks documentation](https://developers.openai.com/codex/hooks) for details. To enable
Serena's hooks for Codex, add this feature flag to `~/.codex/config.toml`:
```toml
[features]
codex_hooks = true
```
Then create `~/.codex/hooks.json` with the following content:
```json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "serena-hooks remind --client=codex"
}
]
}
],
"SessionStart": [
{
"matcher": "startup|resume",
"hooks": [
{
"type": "command",
"command": "serena-hooks activate --client=codex"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "serena-hooks cleanup --client=codex"
}
]
}
]
}
}
```
The hooks will:
- **`activate`**: Prompt the agent to activate the current project and read Serena's instructions
when a Codex session starts or resumes.
- **`remind`**: Nudge the agent to use Serena's symbolic tools when it makes too many consecutive
code-search or code-file-read calls without using Serena tools in between.
- **`cleanup`**: Clean up hook session data when the session ends.
The `PreToolUse` matcher is intentionally restricted to `Bash`. The Serena reminder hook for Codex
tracks shell-based grep and code-file reads, so running it for every tool call is unnecessary.
## Claude Desktop
+148 -18
View File
@@ -66,15 +66,16 @@ class PreToolUseHook(Hook, ABC):
permission_decision_reason: str
additional_context: str = ""
def to_json_string(self) -> str:
def to_json_string(self, client: HookClient) -> str:
hook_output = {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": self.permission_decision,
"permissionDecisionReason": self.permission_decision_reason,
"additionalContext": self.additional_context,
}
}
if client != HookClient.CODEX:
hook_output["hookSpecificOutput"]["additionalContext"] = self.additional_context
return json.dumps(hook_output)
def is_serena_tool(self) -> bool:
@@ -190,8 +191,8 @@ class PreToolUseRemindAboutSerenaHook(PreToolUseHook):
return
now = hook.triggered_at_timestamp
is_grep = hook.is_grep_tool()
is_read = hook.is_read_file_tool()
is_grep = hook.is_grep_call()
is_code_file_read = hook.is_read_code_file_call()
# update grep counter
if is_grep:
@@ -203,7 +204,7 @@ class PreToolUseRemindAboutSerenaHook(PreToolUseHook):
self.last_grep_use_timestamp = now
# update read file counter
if is_read:
if is_code_file_read:
read_period = self._READ_FILE_RESET_PERIOD_SECONDS
if (
self.last_read_file_use_timestamp is not None
@@ -216,7 +217,7 @@ class PreToolUseRemindAboutSerenaHook(PreToolUseHook):
# update combined non-symbolic counter — catches mixed bursts (e.g.
# alternating grep/read) that neither per-tool counter would trip
if is_grep or is_read:
if is_grep or is_code_file_read:
combined_period = self._NON_SYMBOLIC_RESET_PERIOD_SECONDS
if (
self.last_non_symbolic_use_timestamp is not None
@@ -241,25 +242,154 @@ class PreToolUseRemindAboutSerenaHook(PreToolUseHook):
#: ingest. Conservative on purpose: only verbs that strongly imply *reading*
#: a file, never *modifying* one — so ``view_file``/``open_file``/``show_file``
#: are caught alongside ``read_file``, while ``write_file``/``edit_file`` are not.
_READ_FILE_VERB_SUBSTRINGS: tuple[str, ...] = ("read", "view", "open", "show")
_READ_FILE_VERB_SUBSTRINGS: frozenset[str] = frozenset(("read", "view", "open", "show"))
#: Shell commands that perform grep-like search; used to classify Codex
#: shell-command tool calls whose ``cmd`` or ``command`` field starts with one of these.
_GREP_SHELL_COMMANDS: frozenset[str] = frozenset(("grep", "rg", "ag", "ack", "fgrep", "egrep"))
#: Shell commands that perform file-read operations; used to classify Codex
#: shell-command tool calls whose ``cmd`` or ``command`` field starts with one of these.
_READ_SHELL_COMMANDS: frozenset[str] = frozenset(("cat", "head", "tail", "sed", "less", "more", "bat", "get-content", "gc"))
#: file suffixes for source-like files where symbolic tools are usually more
#: appropriate than repeated raw reads. Lowercase and extension-only.
_CODE_FILE_EXTENSIONS: frozenset[str] = frozenset(
(
".al",
".bash",
".c",
".clj",
".cljs",
".cpp",
".cs",
".css",
".dart",
".elm",
".ex",
".exs",
".fs",
".fsx",
".go",
".groovy",
".h",
".hpp",
".hs",
".html",
".java",
".jl",
".js",
".jsx",
".kt",
".kts",
".lean",
".lua",
".m",
".matlab",
".php",
".ps1",
".py",
".r",
".rb",
".rs",
".scala",
".sh",
".sol",
".svelte",
".swift",
".ts",
".tsx",
".vue",
".zig",
)
)
def __init__(self, client: HookClient):
super().__init__(client)
self._tool_call_counter = self.ToolUseCounter.load(self)
def is_grep_tool(self) -> bool:
# extract a shell ``cmd``/``command`` field (Codex shell-command-style payloads):
# split into command name (basename, lowercased) and the remaining argument string
# so both bare names (``rg``) and path-prefixed invocations (``/usr/bin/grep``) are
# normalised the same way. Stays ``None`` when no shell command is present.
self._command: str | None = None
self._command_name: str | None = None
self._command_args_str: str | None = None
# extract a direct file-path field (Claude Code's ``Read``/``Edit``/``Write`` tool
# payloads pass the target as ``file_path`` rather than via a shell command).
self._file_path: str | None = None
if self._tool_input is not None:
self._command = self._tool_input.get("cmd", self._tool_input.get("command", "")).strip()
if self._command:
cmd_split = self._command.split(maxsplit=1)
if len(cmd_split) > 1:
self._command_args_str = cmd_split[1]
self._command_name = os.path.basename(cmd_split[0]).lower()
file_path = self._tool_input.get("file_path") or self._tool_input.get("filePath") or ""
self._file_path = str(file_path).strip() or None
def is_grep_call(self) -> bool:
if self._client == HookClient.CLAUDE_CODE:
return self._tool_name == "grep"
if self._client == HookClient.CODEX and self._is_shell_command_call():
return self._command_name in self._GREP_SHELL_COMMANDS
# heuristic for other clients
return "grep" in self._tool_name
def is_read_file_tool(self) -> bool:
def is_read_call(self) -> bool:
if self._client == HookClient.CLAUDE_CODE:
return self._tool_name == "read"
if self._client == HookClient.CODEX and self._is_shell_command_call():
return self._command_name in self._READ_SHELL_COMMANDS
# heuristic for other clients
name = self._tool_name
if "file" not in name:
return False
return any(verb in name for verb in self._READ_FILE_VERB_SUBSTRINGS)
def _is_shell_command_call(self) -> bool:
""":return: whether the hook payload represents a shell-command tool call."""
return self._command_name is not None
def is_read_file_call(self) -> bool:
""":return: whether the tool call reads a file-like target."""
return self.is_read_call()
def is_read_code_file_call(self) -> bool:
""":return: whether the tool call reads a source-like file target."""
if not self.is_read_file_call():
return False
if self._file_path is not None:
return self._is_code_file_path(self._file_path)
if self._client == HookClient.CODEX and self._command_args_str is not None:
return any(self._is_code_file_path(argument) for argument in self._iter_shell_path_arguments())
return True
def _iter_shell_path_arguments(self) -> list[str]:
""":return: path-like shell arguments with quoting and common options removed."""
arguments: list[str] = []
if self._command_args_str is None:
return arguments
for raw_argument in self._command_args_str.split():
argument = raw_argument.strip().strip("'\"")
if not argument or argument.startswith("-"):
continue
arguments.append(argument)
return arguments
@classmethod
def _is_code_file_path(cls, file_path: str) -> bool:
""":return: whether ``file_path`` has a source-like suffix."""
cleaned_path = file_path.strip().strip("'\"")
if not cleaned_path:
return False
return Path(cleaned_path).suffix.lower() in cls._CODE_FILE_EXTENSIONS
def execute(self) -> None:
# gate the entire hook on the rate-limit window: while we are within
# _MIN_DENY_INTERVAL_SECONDS of the last emitted deny, no counter
@@ -280,14 +410,14 @@ class PreToolUseRemindAboutSerenaHook(PreToolUseHook):
too_many_non_symbolic = self._tool_call_counter.too_many_recent_non_symbolic()
output_data: PreToolUseHook.OutputData | None = None
if self.is_grep_tool() and too_many_greps:
if self.is_grep_call() and too_many_greps:
output_data = self._build_grep_deny()
elif self.is_read_file_tool() and too_many_reads:
output_data = self._build_read_deny()
elif self.is_read_code_file_call() and too_many_reads:
output_data = self._build_code_read_deny()
elif too_many_greps:
output_data = self._build_grep_deny()
elif too_many_reads:
output_data = self._build_read_deny()
output_data = self._build_code_read_deny()
elif too_many_non_symbolic:
output_data = self._build_non_symbolic_deny()
@@ -297,7 +427,7 @@ class PreToolUseRemindAboutSerenaHook(PreToolUseHook):
# ensures we never reach this branch within the rate-limit window.
self._tool_call_counter.reset()
self._tool_call_counter.last_deny_timestamp = self.triggered_at_timestamp
click.echo(output_data.to_json_string())
click.echo(output_data.to_json_string(self._client))
self._tool_call_counter.save(self)
def _build_grep_deny(self) -> "PreToolUseHook.OutputData":
@@ -311,13 +441,13 @@ class PreToolUseRemindAboutSerenaHook(PreToolUseHook):
),
)
def _build_read_deny(self) -> "PreToolUseHook.OutputData":
def _build_code_read_deny(self) -> "PreToolUseHook.OutputData":
return self.OutputData(
permission_decision="deny",
permission_decision_reason="Too many consecutive read calls without using symbolic tools. "
permission_decision_reason="Too many consecutive read calls of files without using symbolic tools. "
"You can continue using read now if needed, the counter was reset.",
additional_context=(
"You were using many read file calls recently. Consider using Serena's symbolic "
"You were using many read calls on files recently. Consider using Serena's symbolic "
"mcp tools instead for more targeted reads. You can continue using read now if needed, the counter was reset."
),
)
@@ -395,7 +525,7 @@ class PreToolUseAutoApproveSerenaHook(PreToolUseHook):
permission_decision="allow",
permission_decision_reason=f"Auto-approved: Serena tool call while client is in {self._permission_mode} mode.",
)
click.echo(output_data.to_json_string())
click.echo(output_data.to_json_string(self._client))
_client_option = click.option(
+95 -78
View File
@@ -2,7 +2,7 @@
Configuration objects for language servers
"""
import fnmatch
import re
from collections.abc import Iterable
from dataclasses import dataclass, field
from enum import Enum
@@ -13,15 +13,25 @@ if TYPE_CHECKING:
class FilenameMatcher:
def __init__(self, *patterns: str) -> None:
def __init__(self, *file_extensions: str) -> None:
"""
:param patterns: fnmatch-compatible patterns
:param file_extensions: file extensions, e.g., `.py, .yml`
"""
self.patterns = patterns
self._file_extensions = file_extensions
def is_relevant_filename(self, fn: str) -> bool:
for pattern in self.patterns:
if fnmatch.fnmatch(fn, pattern):
for ext in self._file_extensions:
if fn.endswith(ext):
return True
return False
def string_contains_relevant_filename(self, string: str) -> bool:
""":return: whether ``string`` contains an occurrence of any registered extension as
a *complete* extension — i.e. the extension must either end the string or be followed
by a non-extension-character (anything other than a letter, digit, or underscore).
"""
for ext in self._file_extensions:
if re.search(rf"{re.escape(ext)}(?:\W|$)", string):
return True
return False
@@ -151,7 +161,7 @@ class Language(str, Enum):
"""
ANSIBLE = "ansible"
"""Ansible language server (experimental) using @ansible/ansible-language-server.
Supports *.yaml and *.yml files (same extensions as YAML, hence experimental).
Supports .yaml and .yml files (same extensions as YAML, hence experimental).
Must be explicitly specified in project.yml. Requires Node.js and npm.
Requires ``ansible`` in PATH for full functionality.
"""
@@ -184,10 +194,11 @@ class Language(str, Enum):
"""
@classmethod
def iter_all(cls, include_experimental: bool = False) -> Iterable[Self]:
def iter_all(cls, include_experimental: bool = False, include_non_programming_languages: bool = True) -> Iterable[Self]:
for lang in cls:
if include_experimental or not lang.is_experimental():
yield lang
if include_non_programming_languages or lang.is_programming_language():
yield lang
def is_experimental(self) -> bool:
"""
@@ -217,6 +228,12 @@ class Language(str, Enum):
self.ANGULAR,
}
def is_programming_language(self) -> bool:
"""Whether the supported language should be considered a programming language.
Solidlsp supports languages like markdown or json, this method returns False for them.
"""
return self not in frozenset((self.MARKDOWN, self.JSON, self.TOML, self.YAML, self.ANSIBLE))
def __str__(self) -> str:
return self.value
@@ -246,149 +263,149 @@ class Language(str, Enum):
def get_source_fn_matcher(self) -> FilenameMatcher:
match self:
case self.PYTHON | self.PYTHON_JEDI | self.PYTHON_TY:
return FilenameMatcher("*.py", "*.pyi")
return FilenameMatcher(".py", ".pyi")
case self.JAVA:
return FilenameMatcher("*.java")
return FilenameMatcher(".java")
case self.TYPESCRIPT | self.TYPESCRIPT_VTS:
# see https://github.com/oraios/serena/issues/204
path_patterns = []
for prefix in ["c", "m", ""]:
for postfix in ["x", ""]:
for base_pattern in ["ts", "js"]:
path_patterns.append(f"*.{prefix}{base_pattern}{postfix}")
path_patterns.append(f".{prefix}{base_pattern}{postfix}")
return FilenameMatcher(*path_patterns)
case self.CSHARP | self.CSHARP_OMNISHARP:
return FilenameMatcher("*.cs")
return FilenameMatcher(".cs")
case self.RUST:
return FilenameMatcher("*.rs")
return FilenameMatcher(".rs")
case self.GO:
return FilenameMatcher("*.go")
return FilenameMatcher(".go")
case self.RUBY:
return FilenameMatcher("*.rb", "*.erb")
return FilenameMatcher(".rb", ".erb")
case self.RUBY_SOLARGRAPH:
return FilenameMatcher("*.rb")
return FilenameMatcher(".rb")
case self.CPP | self.CPP_CCLS:
return FilenameMatcher("*.cpp", "*.h", "*.hpp", "*.c", "*.hxx", "*.cc", "*.cxx")
return FilenameMatcher(".cpp", ".h", ".hpp", ".c", ".hxx", ".cc", ".cxx")
case self.KOTLIN:
return FilenameMatcher("*.kt", "*.kts")
return FilenameMatcher(".kt", ".kts")
case self.DART:
return FilenameMatcher("*.dart")
return FilenameMatcher(".dart")
case self.PHP | self.PHP_PHPACTOR:
return FilenameMatcher("*.php")
return FilenameMatcher(".php")
case self.R:
return FilenameMatcher("*.R", "*.r", "*.Rmd", "*.Rnw")
return FilenameMatcher(".R", ".r", ".Rmd", ".Rnw")
case self.PERL:
return FilenameMatcher("*.pl", "*.pm", "*.t")
return FilenameMatcher(".pl", ".pm", ".t")
case self.CLOJURE:
return FilenameMatcher("*.clj", "*.cljs", "*.cljc", "*.edn") # codespell:ignore edn
return FilenameMatcher(".clj", ".cljs", ".cljc", ".edn") # codespell:ignore edn
case self.ELIXIR:
return FilenameMatcher("*.ex", "*.exs")
return FilenameMatcher(".ex", ".exs")
case self.ELM:
return FilenameMatcher("*.elm")
return FilenameMatcher(".elm")
case self.TERRAFORM:
return FilenameMatcher("*.tf", "*.tfvars", "*.tfstate")
return FilenameMatcher(".tf", ".tfvars", ".tfstate")
case self.SWIFT:
return FilenameMatcher("*.swift")
return FilenameMatcher(".swift")
case self.BASH:
return FilenameMatcher("*.sh", "*.bash")
return FilenameMatcher(".sh", ".bash")
case self.CRYSTAL:
return FilenameMatcher("*.cr")
return FilenameMatcher(".cr")
case self.YAML:
return FilenameMatcher("*.yaml", "*.yml")
return FilenameMatcher(".yaml", ".yml")
case self.JSON:
return FilenameMatcher("*.json", "*.jsonc")
return FilenameMatcher(".json", ".jsonc")
case self.TOML:
return FilenameMatcher("*.toml")
return FilenameMatcher(".toml")
case self.ZIG:
return FilenameMatcher("*.zig", "*.zon")
return FilenameMatcher(".zig", ".zon")
case self.LUA:
return FilenameMatcher("*.lua")
return FilenameMatcher(".lua")
case self.LUAU:
return FilenameMatcher("*.luau")
return FilenameMatcher(".luau")
case self.NIX:
return FilenameMatcher("*.nix")
return FilenameMatcher(".nix")
case self.ERLANG:
return FilenameMatcher("*.erl", "*.hrl", "*.escript", "*.config", "*.app", "*.app.src")
return FilenameMatcher(".erl", ".hrl", ".escript", ".config", ".app", ".app.src")
case self.OCAML:
return FilenameMatcher("*.ml", "*.mli", "*.re", "*.rei")
return FilenameMatcher(".ml", ".mli", ".re", ".rei")
case self.AL:
return FilenameMatcher("*.al", "*.dal")
return FilenameMatcher(".al", ".dal")
case self.FSHARP:
return FilenameMatcher("*.fs", "*.fsx", "*.fsi")
return FilenameMatcher(".fs", ".fsx", ".fsi")
case self.REGO:
return FilenameMatcher("*.rego")
return FilenameMatcher(".rego")
case self.MARKDOWN:
return FilenameMatcher("*.md", "*.markdown")
return FilenameMatcher(".md", ".markdown")
case self.SCALA:
return FilenameMatcher("*.scala", "*.sbt")
return FilenameMatcher(".scala", ".sbt")
case self.JULIA:
return FilenameMatcher("*.jl")
return FilenameMatcher(".jl")
case self.FORTRAN:
return FilenameMatcher(
"*.f90", "*.F90", "*.f95", "*.F95", "*.f03", "*.F03", "*.f08", "*.F08", "*.f", "*.F", "*.for", "*.FOR", "*.fpp", "*.FPP"
".f90", ".F90", ".f95", ".F95", ".f03", ".F03", ".f08", ".F08", ".f", ".F", ".for", ".FOR", ".fpp", ".FPP"
)
case self.HASKELL:
return FilenameMatcher("*.hs", "*.lhs")
return FilenameMatcher(".hs", ".lhs")
case self.HAXE:
return FilenameMatcher("*.hx")
return FilenameMatcher(".hx")
case self.LEAN4:
return FilenameMatcher("*.lean")
return FilenameMatcher(".lean")
case self.VUE:
path_patterns = ["*.vue"]
path_patterns = [".vue"]
for prefix in ["c", "m", ""]:
for postfix in ["x", ""]:
for base_pattern in ["ts", "js"]:
path_patterns.append(f"*.{prefix}{base_pattern}{postfix}")
path_patterns.append(f".{prefix}{base_pattern}{postfix}")
return FilenameMatcher(*path_patterns)
case self.POWERSHELL:
return FilenameMatcher("*.ps1", "*.psm1", "*.psd1")
return FilenameMatcher(".ps1", ".psm1", ".psd1")
case self.PASCAL:
return FilenameMatcher("*.pas", "*.pp", "*.lpr", "*.dpr", "*.dpk", "*.inc")
return FilenameMatcher(".pas", ".pp", ".lpr", ".dpr", ".dpk", ".inc")
case self.GROOVY:
return FilenameMatcher("*.groovy", "*.gvy")
return FilenameMatcher(".groovy", ".gvy")
case self.MATLAB:
return FilenameMatcher("*.m", "*.mlx", "*.mlapp")
return FilenameMatcher(".m", ".mlx", ".mlapp")
case self.HLSL:
return FilenameMatcher(
"*.hlsl",
"*.hlsli",
"*.fx",
"*.fxh",
"*.cginc",
"*.compute",
"*.shader",
"*.glsl",
"*.vert",
"*.frag",
"*.geom",
"*.tesc",
"*.tese",
"*.comp",
"*.wgsl",
".hlsl",
".hlsli",
".fx",
".fxh",
".cginc",
".compute",
".shader",
".glsl",
".vert",
".frag",
".geom",
".tesc",
".tese",
".comp",
".wgsl",
)
case self.SYSTEMVERILOG:
return FilenameMatcher("*.sv", "*.svh", "*.v", "*.vh")
return FilenameMatcher(".sv", ".svh", ".v", ".vh")
case self.SOLIDITY:
return FilenameMatcher("*.sol")
return FilenameMatcher(".sol")
case self.ANSIBLE:
return FilenameMatcher("*.yaml", "*.yml")
return FilenameMatcher(".yaml", ".yml")
case self.MSL:
return FilenameMatcher("*.mrc")
return FilenameMatcher(".mrc")
case self.HTML:
return FilenameMatcher("*.html", "*.htm")
return FilenameMatcher(".html", ".htm")
case self.SCSS:
# *.css is handled by the same engine (vscode-css-languageservice) that powers
# Microsoft's CSS LS, so we route plain CSS through Some Sass too. The CSS feature
# toggles default off upstream and are flipped on at initialization time.
return FilenameMatcher("*.scss", "*.sass", "*.css")
return FilenameMatcher(".scss", ".sass", ".css")
case self.ANGULAR:
# Angular templates can be standalone .html files or inline templates
# within .ts component files; the dual-server architecture handles both.
# SCSS / styles are deliberately NOT subsumed — use Language.SCSS for those.
path_patterns = ["*.html", "*.htm"]
path_patterns = [".html", ".htm"]
for prefix in ["c", "m", ""]:
for postfix in ["x", ""]:
path_patterns.append(f"*.{prefix}ts{postfix}")
path_patterns.append(f".{prefix}ts{postfix}")
return FilenameMatcher(*path_patterns)
case _:
raise ValueError(f"Unhandled language: {self}")
+153 -15
View File
@@ -23,11 +23,24 @@ def _make_stdin(data: dict) -> StringIO:
return StringIO(json.dumps(data))
def _base_input(tool_name: str = "grep_search", session_id: str = "test-session-123") -> dict:
def _base_input(
tool_name: str = "grep_search",
session_id: str = "test-session-123",
tool_input: dict | None = None,
) -> dict:
return {
"session_id": session_id,
"tool_name": tool_name,
"tool_input": {"query": "foo"},
"tool_input": tool_input if tool_input is not None else {"query": "foo"},
}
def _read_input(tool_name: str = "read", session_id: str = "test-session-123", file_path: str = "src/foo.py") -> dict:
"""Build a hook payload for a read-style tool call against a file."""
return {
"session_id": session_id,
"tool_name": tool_name,
"tool_input": {"file_path": file_path},
}
@@ -67,24 +80,44 @@ class TestPreToolUseRemindAboutSerenaHook:
for name, expected in [("grep", True), ("grep_search", False), ("mcp_grep", False), ("read", False)]:
with patch("sys.stdin", _make_stdin(_base_input(tool_name=name))), patch("serena.hooks.serena_home_dir", str(tmp_path)):
hook = PreToolUseRemindAboutSerenaHook(HookClient.CLAUDE_CODE)
assert hook.is_grep_tool() == expected, f"is_grep_tool() wrong for {name} (claude-code)"
assert hook.is_grep_call() == expected, f"is_grep_tool() wrong for {name} (claude-code)"
def test_grep_tool_detection_non_claude_code(self, tmp_path: Path):
"""Non-Claude-Code clients fall back to substring matching to cover verbose tool names."""
for name, expected in [("grep_search", True), ("mcp_grep", True), ("read_file", False), ("serena_find", False)]:
with patch("sys.stdin", _make_stdin(_base_input(tool_name=name))), patch("serena.hooks.serena_home_dir", str(tmp_path)):
hook = PreToolUseRemindAboutSerenaHook(HookClient.VSCODE)
assert hook.is_grep_tool() == expected, f"is_grep_tool() wrong for {name} (vscode)"
assert hook.is_grep_call() == expected, f"is_grep_tool() wrong for {name} (vscode)"
def test_grep_tool_detection_codex_shell_commands(self, tmp_path: Path):
"""Codex shell-command tools are classified by the command embedded in their payload."""
cases = [
("exec_command", {"cmd": "rg -n foo README.md"}, True),
("functions.shell_command", {"command": "rg -n foo README.md"}, True),
("functions.shell_command", {"command": "Get-Content README.md"}, False),
]
for tool_name, tool_input, expected in cases:
with (
patch("sys.stdin", _make_stdin(_base_input(tool_name=tool_name, tool_input=tool_input))),
patch("serena.hooks.serena_home_dir", str(tmp_path)),
):
hook = PreToolUseRemindAboutSerenaHook(HookClient.CODEX)
assert hook.is_grep_call() == expected, f"is_grep_tool() wrong for {tool_name} / {tool_input}"
def test_read_file_tool_detection_claude_code(self, tmp_path: Path):
"""Claude Code uses the exact tool name ``Read`` (lowercased to ``read``)."""
for name, expected in [("read", True), ("read_file", False), ("readFile", False), ("grep", False)]:
with patch("sys.stdin", _make_stdin(_base_input(tool_name=name))), patch("serena.hooks.serena_home_dir", str(tmp_path)):
with (
patch("sys.stdin", _make_stdin(_read_input(tool_name=name))),
patch("serena.hooks.serena_home_dir", str(tmp_path)),
):
hook = PreToolUseRemindAboutSerenaHook(HookClient.CLAUDE_CODE)
assert hook.is_read_file_tool() == expected, f"is_read_file_tool() wrong for {name} (claude-code)"
assert hook.is_read_file_call() == expected, f"is_read_file_tool() wrong for {name} (claude-code)"
def test_read_file_tool_detection_non_claude_code(self, tmp_path: Path):
"""Non-Claude-Code clients accept any read-style verb (``read``/``view``/``open``/``show``) combined with ``file``."""
"""Non-Claude-Code clients accept any read-style verb (``read``/``view``/``open``/``show``)
combined with ``file``.
"""
cases = [
# canonical names
("read_file", True),
@@ -100,9 +133,49 @@ class TestPreToolUseRemindAboutSerenaHook:
("edit_file", False),
]
for name, expected in cases:
with patch("sys.stdin", _make_stdin(_base_input(tool_name=name))), patch("serena.hooks.serena_home_dir", str(tmp_path)):
with (
patch("sys.stdin", _make_stdin(_read_input(tool_name=name))),
patch("serena.hooks.serena_home_dir", str(tmp_path)),
):
hook = PreToolUseRemindAboutSerenaHook(HookClient.VSCODE)
assert hook.is_read_file_tool() == expected, f"is_read_file_tool() wrong for {name} (vscode)"
assert hook.is_read_file_call() == expected, f"is_read_file_tool() wrong for {name} (vscode)"
def test_read_non_code_file_counts_as_file_read(self, tmp_path: Path):
"""A ``Read`` call against a non-source file still counts as a file read."""
payload = _read_input(tool_name="read", file_path="notes/todo.txt")
with patch("sys.stdin", _make_stdin(payload)), patch("serena.hooks.serena_home_dir", str(tmp_path)):
hook = PreToolUseRemindAboutSerenaHook(HookClient.CLAUDE_CODE)
assert hook.is_read_call() is True
assert hook.is_read_file_call() is True
assert hook.is_read_code_file_call() is False
def test_read_code_file_detection(self, tmp_path: Path):
"""Only source-like read targets count for the code-read reminder."""
cases = [
(HookClient.CLAUDE_CODE, _read_input(tool_name="read", file_path="README.md"), False),
(HookClient.CLAUDE_CODE, _read_input(tool_name="read", file_path="src/foo.py"), True),
(HookClient.CODEX, _base_input("functions.shell_command", tool_input={"command": "Get-Content README.md"}), False),
(HookClient.CODEX, _base_input("functions.shell_command", tool_input={"command": "Get-Content src/foo.py"}), True),
]
for client, payload, expected in cases:
with patch("sys.stdin", _make_stdin(payload)), patch("serena.hooks.serena_home_dir", str(tmp_path)):
hook = PreToolUseRemindAboutSerenaHook(client)
assert hook.is_read_code_file_call() == expected, f"is_read_code_file_call() wrong for {client} / {payload}"
def test_read_file_tool_detection_codex_shell_commands(self, tmp_path: Path):
"""Codex shell-command tools count PowerShell file reads, including non-code files."""
cases = [
("exec_command", {"cmd": "cat README.md"}, True),
("functions.shell_command", {"command": "Get-Content README.md"}, True),
("functions.shell_command", {"command": "rg -n foo README.md"}, False),
]
for tool_name, tool_input, expected in cases:
with (
patch("sys.stdin", _make_stdin(_base_input(tool_name=tool_name, tool_input=tool_input))),
patch("serena.hooks.serena_home_dir", str(tmp_path)),
):
hook = PreToolUseRemindAboutSerenaHook(HookClient.CODEX)
assert hook.is_read_file_call() == expected, f"is_read_file_tool() wrong for {tool_name} / {tool_input}"
def test_serena_tool_detection(self, tmp_path: Path):
for name, expected in [("mcp_serena_find_symbol", True), ("serena_overview", True), ("grep_search", False)]:
@@ -141,17 +214,57 @@ class TestPreToolUseRemindAboutSerenaHook:
assert hook_output["permissionDecision"] == "deny"
assert "grep" in hook_output["additionalContext"].lower()
def test_deny_output_after_threshold_greps_codex_shell_command(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]):
"""After reaching the grep threshold, Codex ``functions.shell_command`` emits a deny."""
payload = _base_input("functions.shell_command", tool_input={"command": "rg -n foo README.md"})
for _ in range(ToolUseCounter._GREP_USES_THRESHOLD):
with patch("sys.stdin", _make_stdin(payload)), patch("serena.hooks.serena_home_dir", str(tmp_path)):
PreToolUseRemindAboutSerenaHook(HookClient.CODEX).execute()
output = capsys.readouterr().out.strip()
result = json.loads(output)
hook_output = result["hookSpecificOutput"]
assert hook_output["permissionDecision"] == "deny"
assert "additionalContext" not in hook_output
assert "grep" in hook_output["permissionDecisionReason"].lower()
def test_deny_output_after_threshold_reads(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]):
"""After reaching the read file threshold, the hook should output a deny."""
"""After reaching the read file threshold, the hook should output a deny.
``_read_input`` supplies the direct ``file_path`` field emitted by file-read tools.
"""
for _ in range(ToolUseCounter._READ_FILE_USES_THRESHOLD):
with patch("sys.stdin", _make_stdin(_base_input("read"))), patch("serena.hooks.serena_home_dir", str(tmp_path)):
with patch("sys.stdin", _make_stdin(_read_input("read"))), patch("serena.hooks.serena_home_dir", str(tmp_path)):
PreToolUseRemindAboutSerenaHook(HookClient.CLAUDE_CODE).execute()
output = capsys.readouterr().out.strip()
result = json.loads(output)
hook_output = result["hookSpecificOutput"]
assert hook_output["permissionDecision"] == "deny"
assert "read file" in hook_output["additionalContext"].lower()
assert "read" in hook_output["additionalContext"].lower()
assert "files" in hook_output["additionalContext"].lower()
def test_no_deny_after_threshold_get_content_markdown_codex(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]):
"""PowerShell ``Get-Content`` calls on markdown files do not count for the code-read reminder."""
payload = _base_input("functions.shell_command", tool_input={"command": "Get-Content README.md"})
for _ in range(ToolUseCounter._READ_FILE_USES_THRESHOLD):
with patch("sys.stdin", _make_stdin(payload)), patch("serena.hooks.serena_home_dir", str(tmp_path)):
PreToolUseRemindAboutSerenaHook(HookClient.CODEX).execute()
assert capsys.readouterr().out == ""
def test_deny_output_after_threshold_get_content_code_file_codex(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]):
"""PowerShell ``Get-Content`` calls on source files count for the code-read reminder."""
payload = _base_input("functions.shell_command", tool_input={"command": "Get-Content src/foo.py"})
for _ in range(ToolUseCounter._READ_FILE_USES_THRESHOLD):
with patch("sys.stdin", _make_stdin(payload)), patch("serena.hooks.serena_home_dir", str(tmp_path)):
PreToolUseRemindAboutSerenaHook(HookClient.CODEX).execute()
result = json.loads(capsys.readouterr().out.strip())
hook_output = result["hookSpecificOutput"]
assert hook_output["permissionDecision"] == "deny"
assert "additionalContext" not in hook_output
assert "read" in hook_output["permissionDecisionReason"].lower()
def test_serena_tool_resets_counters(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]):
"""Using a Serena tool should reset counters, so the threshold is not reached."""
@@ -297,7 +410,7 @@ class TestToolUseCounter:
counter.last_read_file_use_timestamp = now - timedelta(seconds=1)
counter.n_recent_read_file_uses = 1
hook = self._make_hook_stub("read_file", now)
hook = self._make_hook_stub("read_file", now, file_path="src/foo.py")
counter.update(hook)
assert counter.n_recent_read_file_uses == 2
@@ -308,11 +421,22 @@ class TestToolUseCounter:
counter.last_read_file_use_timestamp = now - timedelta(seconds=ToolUseCounter._READ_FILE_RESET_PERIOD_SECONDS + 1)
counter.n_recent_read_file_uses = 2
hook = self._make_hook_stub("read_file", now)
hook = self._make_hook_stub("read_file", now, file_path="src/foo.py")
counter.update(hook)
assert counter.n_recent_read_file_uses == 1
def test_update_ignores_read_of_non_code_file(self):
"""A read call whose payload points at a non-source file does not increment the code-read counter."""
counter = ToolUseCounter()
now = datetime.now()
hook = self._make_hook_stub("read_file", now, file_path="notes/todo.txt")
counter.update(hook)
assert counter.n_recent_read_file_uses == 0
assert counter.last_read_file_use_timestamp is None
def test_serena_tool_resets_all_counters(self):
counter = ToolUseCounter(
n_recent_grep_uses=2,
@@ -393,18 +517,32 @@ class TestToolUseCounter:
assert counter.last_deny_timestamp == base
@staticmethod
def _make_hook_stub(tool_name: str, timestamp: datetime) -> PreToolUseRemindAboutSerenaHook:
def _make_hook_stub(
tool_name: str,
timestamp: datetime,
file_path: str | None = None,
command_args_str: str | None = None,
) -> PreToolUseRemindAboutSerenaHook:
"""Create a minimal stub that satisfies ToolUseCounter.update without reading stdin.
Uses ``HookClient.VSCODE`` so that ``is_grep_tool`` / ``is_read_file_tool`` apply the
substring-matching branch — the counter tests below feed verbose tool names like
``grep_search`` / ``read_file`` which are only recognized under the non-Claude-Code
branch (Claude Code uses exact names ``grep`` / ``read``).
:param file_path: optional payload path; the hook classifies read-style tools as
file reads regardless of the path extension.
:param command_args_str: optional shell-argument tail; equivalent to ``file_path`` but
populates the ``cmd``-derived field instead of ``file_path``.
"""
stub = object.__new__(PreToolUseRemindAboutSerenaHook)
stub._tool_name = tool_name.lower()
stub._client = HookClient.VSCODE
stub.triggered_at_timestamp = timestamp
stub._command = None
stub._command_name = None
stub._command_args_str = command_args_str
stub._file_path = file_path
return stub