refactor: add file system watchdog support for API handlers, extensions, and plugins

- Add `watchdog` dependency to requirements.txt - Implement cache entry timestamps with `CacheEntry` dataclass for LRU-style tracking - Add `trim_cache` function to remove stale entries based on age - Update cache operations to track and update entry timestamps on access - Add `_get_matching_areas` helper for wildcard pattern matching in cache clearing - Register watchdogs for API handlers, extensions, and plugins to

frdel committed Mar 18, 2026 at 21:07 UTC e4f974b80dcb2fbb55eb61974cfff6a28c6eceed
11 files changed +627 -32
extensions/python/__main___init_a0_end/_10_register_watchdogs.py new
+11
@@ -0,0 +1,11 @@
1 +from helpers.extension import Extension
2 +
3 +
4 +class RegisterWatchDogs(Extension):
5 +
6 + def execute(self, **kwargs):
7 + from helpers.plugins import register_watchdogs as register_plugins_watchdogs
8 + from helpers.api import register_watchdogs as register_api_watchdogs
9 +
10 + register_plugins_watchdogs()
11 + register_api_watchdogs()
\ No newline at end of file
extensions/python/job_loop/_50_trim_cache.py new
+9
@@ -0,0 +1,9 @@
1 +from typing import Any
2 +from helpers.extension import Extension
3 +from helpers import cache
4 +
5 +
6 +class SaveToolCallFile(Extension):
7 + def execute(self, data: dict[str, Any] | None = None, **kwargs):
8 + # trim unused cache entries
9 + cache.trim_cache("*", seconds=300)
helpers/api.py
+38 -8
@@ -6,7 +6,17 @@ import threading
6 from functools import wraps
7 from pathlib import Path
8 from typing import Union, TypedDict, Dict, Any
9 -from flask import Request, Response, jsonify, Flask, session, request, send_file, redirect, url_for
9 +from flask import (
10 + Request,
11 + Response,
12 + jsonify,
13 + Flask,
14 + session,
15 + request,
16 + send_file,
17 + redirect,
18 + url_for,
19 +)
20 from werkzeug.wrappers.response import Response as BaseResponse
21 from agent import AgentContext
22 from initialize import initialize_agent
@@ -17,7 +27,7 @@ from helpers import files, cache
27 ThreadLockType = Union[threading.Lock, threading.RLock]
28
29 CACHE_AREA = "api_handlers(api)"
20 -cache.toggle_area(CACHE_AREA, False) # cache off for now
30 +cache.toggle_area(CACHE_AREA, False) # cache off for now
31
32 Input = dict
33 Output = Union[Dict[str, Any], Response, TypedDict] # type: ignore
@@ -69,7 +79,6 @@ class ApiHandler:
79 # input_data = {"data": request.get_data(as_text=True)}
80 input_data = {}
81
72 -
82 # process via handler
83 output = await self.process(input_data, request)
84
@@ -102,19 +111,20 @@ class ApiHandler:
111 if got:
112 return got
113 if create_if_not_exists:
105 - context = AgentContext(config=initialize_agent(), id=ctxid, set_current=True)
114 + context = AgentContext(
115 + config=initialize_agent(), id=ctxid, set_current=True
116 + )
117 return context
118 else:
119 raise Exception(f"Context {ctxid} not found")
109 -
110 -
120
121
122 def is_loopback_address(address: str) -> bool:
123 loopback_checker = {
124 socket.AF_INET: lambda x: (
125 struct.unpack("!I", socket.inet_aton(x))[0] >> (32 - 8)
117 - ) == 127,
126 + )
127 + == 127,
128 socket.AF_INET6: lambda x: x == "::1",
129 }
130 address_type = "hostname"
@@ -148,6 +158,7 @@ def requires_api_key(f):
158 @wraps(f)
159 async def decorated(*args, **kwargs):
160 from helpers.settings import get_settings
161 +
162 valid_api_key = get_settings()["mcp_server_token"]
163
164 if api_key := request.headers.get("X-API-KEY"):
@@ -178,6 +189,7 @@ def requires_auth(f):
189 @wraps(f)
190 async def decorated(*args, **kwargs):
191 from helpers import login
192 +
193 user_pass_hash = login.get_credentials_hash()
194 if not user_pass_hash:
195 return await f(*args, **kwargs)
@@ -192,6 +204,7 @@ def csrf_protect(f):
204 @wraps(f)
205 async def decorated(*args, **kwargs):
206 from helpers import runtime
207 +
208 token = session.get("csrf_token")
209 header = request.headers.get("X-CSRF-Token")
210 cookie = request.cookies.get("csrf_token_" + runtime.get_runtime_id())
@@ -219,7 +232,9 @@ def register_api_route(app: Flask, lock: ThreadLockType) -> None:
232
233 # Check built-in python/api/<path>.py
234 builtin_file = files.get_abs_path(f"api/{path}.py")
222 - if files.is_in_dir(builtin_file, files.get_abs_path("api")) and files.exists(builtin_file):
235 + if files.is_in_dir(builtin_file, files.get_abs_path("api")) and files.exists(
236 + builtin_file
237 + ):
238 classes = load_classes_from_file(builtin_file, ApiHandler)
239 if classes:
240 handler_cls = classes[0]
@@ -269,3 +284,18 @@ def register_api_route(app: Flask, lock: ThreadLockType) -> None:
284 methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
285 )
286
287 +
288 +def register_watchdogs():
289 + from helpers import watchdog
290 +
291 + def on_api_change(items:list[watchdog.WatchItem]):
292 + PrintStyle.debug("API endpoint watchdog triggered:", items)
293 + cache.clear(CACHE_AREA)
294 +
295 +
296 + watchdog.add_watchdog(
297 + "api_handlers",
298 + roots=[files.get_abs_path("api")],
299 + patterns=["*.py"],
300 + handler=on_api_change,
301 + )
helpers/cache.py
+50 -4
@@ -1,14 +1,22 @@
1 import fnmatch
2 import threading
3 +import time
4 +from dataclasses import dataclass
5 from typing import Any
6
7 _lock = threading.RLock()
6 -_cache: dict[str, dict[str, Any]] = {}
8 +_cache: dict[str, dict[str, "CacheEntry"]] = {}
9
10 _enabled_global: bool = True
11 _enabled_areas: dict[str, bool] = {}
12
13
14 +@dataclass(slots=True)
15 +class CacheEntry:
16 + value: Any
17 + timestamp: float
18 +
19 +
20 def toggle_global(enabled: bool) -> None:
21 global _enabled_global
22 _enabled_global = enabled
@@ -22,7 +30,11 @@ def has(area: str, key: Any) -> bool:
30 if not _is_enabled(area):
31 return False
32 with _lock:
25 - return key in _cache.get(area, {})
33 + entry = _cache.get(area, {}).get(key)
34 + if entry is None:
35 + return False
36 + _touch_entry(entry)
37 + return True
38
39
40 def add(area: str, key: Any, data: Any) -> None:
@@ -31,14 +43,18 @@ def add(area: str, key: Any, data: Any) -> None:
43 with _lock:
44 if area not in _cache:
45 _cache[area] = {}
34 - _cache[area][key] = data
46 + _cache[area][key] = _create_entry(data)
47
48
49 def get(area: str, key: Any, default: Any = None) -> Any:
50 if not _is_enabled(area):
51 return default
52 with _lock:
41 - return _cache.get(area, {}).get(key, default)
53 + entry = _cache.get(area, {}).get(key)
54 + if entry is None:
55 + return default
56 + _touch_entry(entry)
57 + return entry.value
58
59
60 def remove(area: str, key: Any) -> None:
@@ -60,6 +76,22 @@ def clear(area: str) -> None:
76 _cache.pop(area, None)
77
78
79 +def trim_cache(area: str, seconds: float = 300) -> None:
80 + cutoff = time.time() - seconds
81 + with _lock:
82 + for area_key in _get_matching_areas(area):
83 + area_cache = _cache.get(area_key)
84 + if not area_cache:
85 + continue
86 +
87 + keys_to_remove = [key for key, entry in area_cache.items() if entry.timestamp < cutoff]
88 + for key in keys_to_remove:
89 + area_cache.pop(key, None)
90 +
91 + if not area_cache:
92 + _cache.pop(area_key, None)
93 +
94 +
95 def clear_all() -> None:
96 with _lock:
97 _cache.clear()
@@ -71,6 +103,20 @@ def _is_enabled(area: str) -> bool:
103 return _enabled_areas.get(area, True)
104
105
106 +def _create_entry(value: Any) -> CacheEntry:
107 + return CacheEntry(value=value, timestamp=time.time())
108 +
109 +
110 +def _touch_entry(entry: CacheEntry) -> None:
111 + entry.timestamp = time.time()
112 +
113 +
114 +def _get_matching_areas(area: str) -> list[str]:
115 + if any(ch in area for ch in "*?["):
116 + return [k for k in _cache.keys() if fnmatch.fnmatch(k, area)]
117 + return [area]
118 +
119 +
120 def determine_cache_key(agent, *additional):
121 if agent:
122 profile = agent.config.profile or "none"
helpers/extension.py
+21 -1
@@ -7,6 +7,8 @@ from functools import wraps
7 import inspect
8 import os
9
10 +from helpers.print_style import PrintStyle
11 +
12 if TYPE_CHECKING:
13 from agent import Agent
14
@@ -294,4 +296,22 @@ def _get_extensions(folder: str):
296
297 classes = extract_tools.load_classes_from_folder(folder, "*", Extension)
298 cache.add(_EXTENSIONS_CACHE_AREA, folder, classes)
297 - return classes
\ No newline at end of file
299 + return classes
300 +
301 +def register_extensions_watchdogs():
302 + from helpers import watchdog
303 +
304 + def extensions_changed(items: list[watchdog.WatchItem]):
305 + cache.clear(_EXTENSIONS_CACHE_AREA)
306 + cache.clear(_CLASSES_CACHE_AREA)
307 + PrintStyle.debug("Extensions watchdog triggered:", items)
308 +
309 + watchdog.add_watchdog(
310 + id="extensions",
311 + roots=[
312 + files.get_abs_path(files.EXTENSIONS_DIR),
313 + files.get_abs_path(files.USER_DIR, files.EXTENSIONS_DIR)
314 + ],
315 + handler=extensions_changed
316 + )
317 + # TODO - watch extensions under projects/agents
\ No newline at end of file
helpers/files.py
+1
@@ -16,6 +16,7 @@ from helpers import yaml
16 AGENTS_DIR = "agents"
17 PLUGINS_DIR = "plugins"
18 PROJECTS_DIR = "projects"
19 +EXTENSIONS_DIR = "extensions"
20 USER_DIR = "usr"
21 TEMP_DIR = "tmp"
22 _base_dir = os.path.dirname(os.path.abspath(os.path.join(__file__, "../")))
helpers/plugins.py
+74 -13
@@ -15,6 +15,8 @@ from typing import (
15 TypedDict,
16 )
17
18 +from regex import W
19 +
20 from helpers import (
21 files,
22 git,
@@ -23,11 +25,13 @@ from helpers import (
25 yaml as yaml_helper,
26 cache,
27 extension,
28 + watchdog,
29 extract_tools,
30 )
31 from pydantic import BaseModel, Field
32
33 from helpers.defer import DeferredTask
34 +from helpers.watchdog import WatchItem
35
36 if TYPE_CHECKING:
37 from agent import Agent
@@ -110,6 +114,53 @@ class PluginUpdateInfo(BaseModel):
114 error: str = ""
115
116
117 +def register_watchdogs():
118 +
119 + def on_plugin_change(events: list[WatchItem]):
120 + plugin_names: list[str] = []
121 + for path, _event in events:
122 + path = path.replace("\\", "/")
123 + if "/plugins/" not in path:
124 + continue
125 + plugin_name = path.split("/plugins/", 1)[1].split("/", 1)[0]
126 + if plugin_name and plugin_name not in plugin_names:
127 + plugin_names.append(plugin_name)
128 + print_style.PrintStyle.debug("Plugins watchdog triggered", plugin_names)
129 + after_plugin_change(plugin_names or None)
130 +
131 + # add watchdogs for plugin roots
132 + watchdog.add_watchdog(
133 + id="plugins_roots",
134 + roots=get_plugin_roots(),
135 + handler=on_plugin_change,
136 + )
137 +
138 + from helpers import projects
139 + from helpers import subagents
140 +
141 + # add watchdogs for plugin overrides in projects/plugins and projects/agents/plugins
142 + watchdog.add_watchdog(
143 + id="plugins_projects",
144 + roots=[files.get_abs_path(projects.PROJECTS_PARENT_DIR)],
145 + patterns=[
146 + f"*/{projects.PROJECT_META_DIR}/plugins/**/*",
147 + f"*/{projects.PROJECT_META_DIR}/agents/*/plugins/**/*",
148 + ],
149 + handler=on_plugin_change,
150 + )
151 +
152 + # add watchdogs for plugin overrides in /agents/plugins and /usr/agents/plugins
153 + watchdog.add_watchdog(
154 + id="plugins_agents",
155 + roots=[
156 + files.get_abs_path(subagents.DEFAULT_AGENTS_DIR),
157 + files.get_abs_path(subagents.USER_AGENTS_DIR),
158 + ],
159 + patterns=[f"*/plugins/**/*"],
160 + handler=on_plugin_change,
161 + )
162 +
163 +
164 @extension.extensible
165 def after_plugin_change(plugin_names: list[str] | None = None):
166 clear_plugin_cache()
@@ -215,8 +266,12 @@ def get_enhanced_plugins_list(
266 return results
267
268
218 -def get_custom_plugins_updates(plugin_names: list[str] | None = None) -> List[PluginUpdateInfo]:
219 - plugins = get_enhanced_plugins_list(custom=True, builtin=False, plugin_names=plugin_names)
269 +def get_custom_plugins_updates(
270 + plugin_names: list[str] | None = None,
271 +) -> List[PluginUpdateInfo]:
272 + plugins = get_enhanced_plugins_list(
273 + custom=True, builtin=False, plugin_names=plugin_names
274 + )
275 results: list[PluginUpdateInfo] = []
276
277 for plugin in plugins:
@@ -276,6 +331,7 @@ def uninstall_plugin(plugin_name):
331 # then delete
332 delete_plugin(plugin_name)
333
334 +
335 @extension.extensible
336 def delete_plugin(plugin_name: str):
337 plugin_dir = find_plugin_dir(plugin_name)
@@ -302,7 +358,9 @@ def get_plugin_paths(*subpaths: str) -> List[str]:
358
359
360 def get_enabled_plugin_paths(agent: Agent | None, *subpaths: str) -> List[str]:
305 - if cached := cache.get(ENABLED_PLUGINS_PATHS_CACHE_AREA, cache.determine_cache_key(agent, *subpaths)):
361 + if cached := cache.get(
362 + ENABLED_PLUGINS_PATHS_CACHE_AREA, cache.determine_cache_key(agent, *subpaths)
363 + ):
364 return cached
365
366 enabled = get_enabled_plugins(agent)
@@ -321,14 +379,19 @@ def get_enabled_plugin_paths(agent: Agent | None, *subpaths: str) -> List[str]:
379 path_pattern = files.get_abs_path(base_dir, *subpaths)
380 paths.extend(files.find_existing_paths_by_pattern(path_pattern))
381
324 -
325 - cache.add(ENABLED_PLUGINS_PATHS_CACHE_AREA, cache.determine_cache_key(agent, *subpaths), paths)
382 + cache.add(
383 + ENABLED_PLUGINS_PATHS_CACHE_AREA,
384 + cache.determine_cache_key(agent, *subpaths),
385 + paths,
386 + )
387
388 return paths
389
390
391 def get_enabled_plugins(agent: Agent | None):
331 - if cached := cache.get(ENABLED_PLUGINS_LIST_CACHE_AREA, cache.determine_cache_key(agent)):
392 + if cached := cache.get(
393 + ENABLED_PLUGINS_LIST_CACHE_AREA, cache.determine_cache_key(agent)
394 + ):
395 return cached
396
397 plugins = get_plugins_list()
@@ -496,7 +559,7 @@ def get_plugin_config(
559 agent_profile=agent_profile,
560 )
561
499 - return result
562 + return result
563
564
565 def get_default_plugin_config(plugin_name: str):
@@ -506,9 +569,7 @@ def get_default_plugin_config(plugin_name: str):
569
570 # call plugin hook to get the result
571 result = call_plugin_hook(
509 - plugin_name,
510 - "get_default_plugin_config",
511 - file_path = file_path
572 + plugin_name, "get_default_plugin_config", file_path=file_path
573 )
574
575 # or do standard load
@@ -544,8 +605,6 @@ def save_plugin_config(
605 after_plugin_change([plugin_name])
606
607
547 -
548 -
608 def find_plugin_asset(
609 plugin_name: str, *subpaths: str, project_name="", agent_profile=""
610 ):
@@ -732,7 +791,9 @@ def send_frontend_reload_notification(plugin_names: list[str] | None = None):
791 DeferredTask().start_task(_send_later)
792
793
735 -def call_plugin_hook(plugin_name: str, hook_name: str, default: Any=None, *args, **kwargs):
794 +def call_plugin_hook(
795 + plugin_name: str, hook_name: str, default: Any = None, *args, **kwargs
796 +):
797 hooks = None
798
799 # use cached hooks if enabled
helpers/watchdog.py new
+422
@@ -0,0 +1,422 @@
1 +from __future__ import annotations
2 +
3 +import os
4 +import threading
5 +from dataclasses import dataclass
6 +from pathlib import PurePosixPath
7 +from typing import Any, Callable, Iterable, Literal, cast
8 +from watchdog.observers import Observer as _WatchdogObserver
9 +
10 +
11 +class _DispatchHandler:
12 + def __init__(self, registry: "_WatchRegistry", scheduled_root: str):
13 + self.registry = registry
14 + self.scheduled_root = scheduled_root
15 +
16 + def dispatch(self, event: Any):
17 + self.registry.dispatch(self.scheduled_root, event)
18 +
19 +
20 +WatchEvent = Literal["create", "modify", "delete", "move"]
21 +WatchEvents = Literal["all"] | list[WatchEvent | str] | set[WatchEvent | str]
22 +WatchItem = list[str]
23 +WatchHandler = Callable[[list[WatchItem]], None]
24 +PatternMatcher = Callable[[str], bool]
25 +
26 +_DEFAULT_PATTERNS = ["**/*"]
27 +_DEFAULT_IGNORE_PATTERNS = [
28 + "**/__pycache__",
29 + "**/__pycache__/*",
30 + "**/*.pyc",
31 + "**/*.pyo",
32 +]
33 +_VALID_EVENTS: frozenset[WatchEvent] = frozenset(["create", "modify", "delete", "move"])
34 +_EVENT_ALIASES: dict[str, WatchEvent] = {
35 + "create": "create",
36 + "created": "create",
37 + "modify": "modify",
38 + "modified": "modify",
39 + "delete": "delete",
40 + "deleted": "delete",
41 + "move": "move",
42 + "moved": "move",
43 +}
44 +
45 +
46 +@dataclass(frozen=True)
47 +class _Watch:
48 + id: str
49 + root: str
50 + root_with_sep: str
51 + patterns: list[str]
52 + ignore_patterns: list[str]
53 + matcher: PatternMatcher
54 + events: frozenset[WatchEvent]
55 + debounce: float
56 + handler: WatchHandler
57 +
58 +
59 +@dataclass
60 +class _PendingBatch:
61 + items_by_path: dict[str, WatchItem]
62 + timer: threading.Timer | None = None
63 +
64 +
65 +class _WatchRegistry:
66 + def __init__(self):
67 + self._lock = threading.RLock()
68 + self._observer: Any = None
69 + self._watches: dict[str, _Watch] = {}
70 + self._watch_ids_by_group: dict[str, set[str]] = {}
71 + self._scheduled_roots: set[str] = set()
72 + self._pending_batches: dict[str, _PendingBatch] = {}
73 +
74 + def add(
75 + self,
76 + id: str,
77 + roots: list[str],
78 + patterns: list[str] | None,
79 + ignore_patterns: list[str] | None,
80 + events: WatchEvents,
81 + debounce: float,
82 + handler: WatchHandler,
83 + ) -> None:
84 + self._ensure_watchdog_available()
85 + normalized_roots = _normalize_roots(roots)
86 + normalized_patterns = _normalize_patterns(patterns)
87 + normalized_ignore_patterns = _normalize_patterns(
88 + ignore_patterns, default=_DEFAULT_IGNORE_PATTERNS
89 + )
90 + normalized_events = _normalize_events(events)
91 + normalized_debounce = _normalize_debounce(debounce)
92 + watch_ids = [id] if len(normalized_roots) == 1 else [f"{id}:{index}" for index in range(len(normalized_roots))]
93 + watches = {
94 + watch_id: _Watch(
95 + id=watch_id,
96 + root=normalized_root,
97 + root_with_sep=normalized_root + os.sep,
98 + patterns=normalized_patterns,
99 + ignore_patterns=normalized_ignore_patterns,
100 + matcher=_compile_matcher(
101 + normalized_root,
102 + normalized_patterns,
103 + normalized_ignore_patterns,
104 + ),
105 + events=normalized_events,
106 + debounce=normalized_debounce,
107 + handler=handler,
108 + )
109 + for watch_id, normalized_root in zip(watch_ids, normalized_roots)
110 + }
111 + with self._lock:
112 + previous_watch_ids = self._watch_ids_by_group.pop(id, set())
113 + for watch_id in previous_watch_ids:
114 + self._watches.pop(watch_id, None)
115 + pending = self._pending_batches.pop(watch_id, None)
116 + if pending and pending.timer:
117 + pending.timer.cancel()
118 + self._watches.update(watches)
119 + self._watch_ids_by_group[id] = set(watches)
120 + self._refresh_observer()
121 +
122 + def remove(self, id: str) -> bool:
123 + with self._lock:
124 + watch_ids = self._watch_ids_by_group.pop(id, {id})
125 + removed = False
126 + for watch_id in watch_ids:
127 + removed = self._watches.pop(watch_id, None) is not None or removed
128 + pending = self._pending_batches.pop(watch_id, None)
129 + if pending and pending.timer:
130 + pending.timer.cancel()
131 + if removed:
132 + self._refresh_observer()
133 + return removed
134 +
135 + def clear(self) -> None:
136 + with self._lock:
137 + self._watches.clear()
138 + self._watch_ids_by_group.clear()
139 + pending_batches = list(self._pending_batches.values())
140 + self._pending_batches.clear()
141 + self._refresh_observer()
142 + for pending in pending_batches:
143 + if pending.timer:
144 + pending.timer.cancel()
145 +
146 + def start(self) -> None:
147 + with self._lock:
148 + observer = self._observer
149 + if observer is None:
150 + observer = self._create_observer()
151 + self._observer = observer
152 + if observer.is_alive():
153 + return
154 + observer.start()
155 +
156 + def stop(self) -> None:
157 + self._stop_observer()
158 +
159 + def dispatch(self, scheduled_root: str, event: Any) -> None:
160 + event_type = _map_event_type(str(getattr(event, "event_type", "")))
161 + if event_type is None:
162 + return
163 + if bool(getattr(event, "is_synthetic", False)):
164 + return
165 + paths: list[str] = []
166 + src_path = getattr(event, "src_path", None)
167 + if isinstance(src_path, str) and src_path:
168 + paths.append(os.path.abspath(src_path))
169 + dest_path = getattr(event, "dest_path", None)
170 + if event_type == "move" and isinstance(dest_path, str) and dest_path:
171 + paths.append(os.path.abspath(dest_path))
172 + with self._lock:
173 + watches = list(self._watches.values())
174 + for path in paths:
175 + if not _is_same_or_nested(path, scheduled_root):
176 + continue
177 + for watch in watches:
178 + if event_type not in watch.events:
179 + continue
180 + if not _is_under_watch(path, watch):
181 + continue
182 + if not watch.matcher(path):
183 + continue
184 + self._queue_event(watch, path, event_type)
185 +
186 + def _ensure_watchdog_available(self) -> None:
187 + return None
188 +
189 + def _queue_event(self, watch: _Watch, path: str, event_type: WatchEvent) -> None:
190 + item: WatchItem = [path, event_type]
191 + if watch.debounce <= 0:
192 + watch.handler([item])
193 + return
194 + with self._lock:
195 + pending = self._pending_batches.get(watch.id)
196 + if pending is None:
197 + pending = _PendingBatch(items_by_path={})
198 + self._pending_batches[watch.id] = pending
199 + pending.items_by_path[path] = item
200 + timer = pending.timer
201 + if timer:
202 + timer.cancel()
203 + pending.timer = threading.Timer(watch.debounce, self._flush_watch_batch, args=(watch.id,))
204 + pending.timer.daemon = True
205 + pending.timer.start()
206 +
207 + def _flush_watch_batch(self, watch_id: str) -> None:
208 + items: list[WatchItem] = []
209 + handler: WatchHandler | None = None
210 + with self._lock:
211 + watch = self._watches.get(watch_id)
212 + pending = self._pending_batches.pop(watch_id, None)
213 + if watch is None or pending is None:
214 + return
215 + if pending.timer:
216 + pending.timer.cancel()
217 + items = list(pending.items_by_path.values())
218 + handler = watch.handler
219 + if items:
220 + handler(items)
221 +
222 + def _refresh_observer(self) -> None:
223 + target_roots = _covering_roots(watch.root for watch in self._watches.values())
224 + if not target_roots:
225 + self._stop_observer()
226 + return
227 + observer = self._observer
228 + if observer is None:
229 + observer = self._create_observer()
230 + self._observer = observer
231 + observer.start()
232 + if target_roots == self._scheduled_roots:
233 + return
234 + observer = cast(Any, observer)
235 + observer.unschedule_all()
236 + for root in target_roots:
237 + observer.schedule(_DispatchHandler(self, root), root, recursive=True)
238 + self._scheduled_roots = target_roots
239 +
240 + def _stop_observer(self) -> None:
241 + with self._lock:
242 + observer = self._observer
243 + self._observer = None
244 + self._scheduled_roots = set()
245 + if observer is None:
246 + return
247 + observer.unschedule_all()
248 + observer.stop()
249 + observer.join()
250 +
251 + def _create_observer(self) -> Any:
252 + observer = cast(Any, _WatchdogObserver())
253 + return observer
254 +
255 +
256 +def _normalize_root(root: str) -> str:
257 + normalized = os.path.abspath(os.path.normpath(root))
258 + if not os.path.exists(normalized):
259 + raise FileNotFoundError(normalized)
260 + if not os.path.isdir(normalized):
261 + raise NotADirectoryError(normalized)
262 + return normalized
263 +
264 +
265 +def _normalize_roots(roots: list[str]) -> list[str]:
266 + normalized = list(dict.fromkeys(_normalize_root(item) for item in roots))
267 + if not normalized:
268 + raise ValueError("roots must not be empty")
269 + return normalized
270 +
271 +
272 +def _normalize_patterns(
273 + patterns: list[str] | None,
274 + default: list[str] | None = None,
275 +) -> list[str]:
276 + default = default or _DEFAULT_PATTERNS
277 + if not patterns:
278 + return list(default)
279 + normalized = [pattern.strip().replace("\\", "/") for pattern in patterns if pattern and pattern.strip()]
280 + return normalized or default
281 +
282 +
283 +def _normalize_events(events: WatchEvents) -> frozenset[WatchEvent]:
284 + if events == "all":
285 + return _VALID_EVENTS
286 + normalized: set[WatchEvent] = set()
287 + for event in events:
288 + mapped = _map_event_type(str(event))
289 + if mapped is None:
290 + raise ValueError(f"Unsupported watch event: {event}")
291 + normalized.add(mapped)
292 + return frozenset(normalized) if normalized else _VALID_EVENTS
293 +
294 +
295 +def _map_event_type(event_type: str) -> WatchEvent | None:
296 + return _EVENT_ALIASES.get(event_type.lower())
297 +
298 +
299 +def _normalize_debounce(debounce: float) -> float:
300 + if debounce < 0:
301 + raise ValueError("debounce must be >= 0")
302 + return debounce
303 +
304 +
305 +def _covering_roots(roots: Iterable[str]) -> set[str]:
306 + ordered = sorted(set(roots), key=lambda root: (len(root), root))
307 + covered: set[str] = set()
308 + for root in ordered:
309 + if any(_is_same_or_nested(root, parent) for parent in covered):
310 + continue
311 + covered.add(root)
312 + return covered
313 +
314 +
315 +def _is_same_or_nested(path: str, root: str) -> bool:
316 + return path == root or path.startswith(root + os.sep)
317 +
318 +
319 +def _is_under_watch(path: str, watch: _Watch) -> bool:
320 + return path == watch.root or path.startswith(watch.root_with_sep)
321 +
322 +
323 +def _compile_matcher(
324 + root: str,
325 + patterns: list[str],
326 + ignore_patterns: list[str],
327 +) -> PatternMatcher:
328 + include_matcher = _compile_single_matcher(root, patterns)
329 + ignore_matcher = _compile_single_matcher(root, ignore_patterns)
330 +
331 + def matches(path: str) -> bool:
332 + return include_matcher(path) and not ignore_matcher(path)
333 +
334 + return matches
335 +
336 +
337 +def _compile_single_matcher(root: str, patterns: list[str]) -> PatternMatcher:
338 + if not patterns or patterns == _DEFAULT_PATTERNS:
339 + return lambda path: True
340 +
341 + if any(pattern in {"**", "**/*", "*"} for pattern in patterns):
342 + return lambda path: True
343 +
344 + relative_patterns = [pattern for pattern in patterns if "/" in pattern]
345 + name_patterns = [
346 + pattern for pattern in patterns if "/" not in pattern and pattern not in {"**", "**/*", "*"}
347 + ]
348 +
349 + def matches(path: str) -> bool:
350 + relative = os.path.relpath(path, root).replace("\\", "/")
351 + if relative == ".":
352 + relative = ""
353 + relative_path = PurePosixPath(relative) if relative else PurePosixPath("")
354 + name_path = PurePosixPath(os.path.basename(path))
355 +
356 + for pattern in relative_patterns:
357 + if relative and relative_path.match(pattern):
358 + return True
359 + for pattern in name_patterns:
360 + if name_path.match(pattern):
361 + return True
362 + if relative and relative_path.match(pattern):
363 + return True
364 + return False
365 +
366 + return matches
367 +
368 +
369 +_registry = _WatchRegistry()
370 +_registry.start()
371 +
372 +
373 +def add_watchdog(
374 + id: str,
375 + roots: list[str],
376 + patterns: list[str] | None = None,
377 + ignore_patterns: list[str] | None = None,
378 + events: WatchEvents = "all",
379 + debounce: float = 0.01,
380 + handler: WatchHandler | None = None,
381 +) -> None:
382 + if handler is None:
383 + raise ValueError("handler is required")
384 + _registry.add(
385 + id=id,
386 + roots=roots,
387 + patterns=patterns,
388 + ignore_patterns=ignore_patterns,
389 + events=events,
390 + debounce=debounce,
391 + handler=handler,
392 + )
393 +
394 +
395 +def remove_watchdog(id: str) -> bool:
396 + return _registry.remove(id)
397 +
398 +
399 +def clear_watchdogs() -> None:
400 + _registry.clear()
401 +
402 +
403 +def start_watchdog_daemon() -> None:
404 + _registry.start()
405 +
406 +
407 +def stop_watchdog_daemon() -> None:
408 + _registry.stop()
409 +
410 +
411 +__all__ = [
412 + "WatchEvent",
413 + "WatchEvents",
414 + "WatchItem",
415 + "WatchHandler",
416 + "add_watchdog",
417 + "remove_watchdog",
418 + "clear_watchdogs",
419 + "start_watchdog_daemon",
420 + "stop_watchdog_daemon",
421 +]
422 +
models.py
-6
@@ -845,12 +845,6 @@ def _parse_chunk(chunk: Any) -> ChatChunk:
845
846
847 def _adjust_call_args(provider_name: str, model_name: str, kwargs: dict):
848 - # for openrouter add app reference
849 - if provider_name == "openrouter":
850 - kwargs["extra_headers"] = {
851 - "HTTP-Referer": "https://agent-zero.ai",
852 - "X-Title": "Agent Zero",
853 - }
848
849 # remap other to openai for litellm
850 if provider_name == "other":
plugins/_infection_check/.toggle-0
requirements.txt
+1
@@ -51,6 +51,7 @@ exchangelib>=5.4.3
51 pywinpty==3.0.2; sys_platform == "win32"
52 python-socketio>=5.14.2
53 uvicorn>=0.38.0
54 +watchdog==6.0.0
55 wsproto>=1.2.0
56 # Security floor pins for transitive dependencies
57 # These packages are pulled transitively — floor pins prevent resolver regressions