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