Skip to content

Commit 3bb516a

Browse files
committed
feat(plugin): add adaptive performance mode for hooks (#1002)
When hook execution time exceeds threshold, auto-switch to lightweight mode that skips heavy analysis (conflict prediction, full project scan) while keeping core functions (mode detection, basic validation). - Add adaptive_perf.py with sliding-window performance monitor - Integrate into session-start.py (skip project scan in lightweight mode) - Integrate into pre-tool-use.py (skip file watcher in lightweight mode) - Configurable threshold via codingbuddy.config.json (hooks.timeoutMs) - User notification on mode switch (i18n: en/ko/ja/zh/es) - 27 tests passing
1 parent 8b009fc commit 3bb516a

4 files changed

Lines changed: 534 additions & 12 deletions

File tree

Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
"""Adaptive performance mode for CodingBuddy hooks (#1002).
2+
3+
Monitors hook execution times and auto-switches to lightweight mode
4+
when the threshold is exceeded. In lightweight mode, heavy analysis
5+
(conflict prediction, full project scan) is skipped while core
6+
functions (mode detection, basic validation) are preserved.
7+
"""
8+
import sys
9+
import time
10+
from typing import Any, Dict, List, Optional
11+
12+
13+
# Default threshold in milliseconds
14+
DEFAULT_TIMEOUT_MS = 10000
15+
16+
# Operations classified by weight
17+
HEAVY_OPERATIONS = frozenset({
18+
"conflict_prediction",
19+
"full_project_scan",
20+
"agent_recommendations",
21+
"file_watcher",
22+
})
23+
24+
CORE_OPERATIONS = frozenset({
25+
"mode_detection",
26+
"basic_validation",
27+
"stats_recording",
28+
"history_recording",
29+
"prompt_injection",
30+
"hook_install",
31+
})
32+
33+
34+
class AdaptivePerformanceMonitor:
35+
"""Monitors hook performance and controls lightweight mode transitions.
36+
37+
Tracks execution times per hook and switches to lightweight mode
38+
when recent execution times consistently exceed the configured
39+
threshold. The monitor uses a sliding window of recent timings
40+
to make decisions.
41+
"""
42+
43+
_instance: Optional["AdaptivePerformanceMonitor"] = None
44+
45+
def __init__(self, timeout_ms: int = DEFAULT_TIMEOUT_MS) -> None:
46+
self._timeout_ms = timeout_ms
47+
self._lightweight_mode = False
48+
self._timings: Dict[str, List[float]] = {}
49+
self._active_timers: Dict[str, float] = {}
50+
self._switch_count = 0
51+
self._window_size = 5
52+
53+
@classmethod
54+
def get_instance(
55+
cls, config: Optional[Dict[str, Any]] = None
56+
) -> "AdaptivePerformanceMonitor":
57+
"""Get or create the singleton monitor instance.
58+
59+
Args:
60+
config: Optional config dict; reads hooks.timeoutMs if present.
61+
62+
Returns:
63+
The singleton AdaptivePerformanceMonitor.
64+
"""
65+
if cls._instance is None:
66+
timeout_ms = DEFAULT_TIMEOUT_MS
67+
if config:
68+
hooks_cfg = config.get("hooks", {})
69+
timeout_ms = hooks_cfg.get("timeoutMs", DEFAULT_TIMEOUT_MS)
70+
cls._instance = cls(timeout_ms=timeout_ms)
71+
return cls._instance
72+
73+
@classmethod
74+
def reset_instance(cls) -> None:
75+
"""Reset the singleton (for testing)."""
76+
cls._instance = None
77+
78+
@property
79+
def is_lightweight(self) -> bool:
80+
"""Whether lightweight mode is currently active."""
81+
return self._lightweight_mode
82+
83+
@property
84+
def timeout_ms(self) -> int:
85+
"""Current timeout threshold in milliseconds."""
86+
return self._timeout_ms
87+
88+
@property
89+
def switch_count(self) -> int:
90+
"""Number of times mode has been switched."""
91+
return self._switch_count
92+
93+
def start_timing(self, hook_name: str) -> None:
94+
"""Start timing a hook execution.
95+
96+
Args:
97+
hook_name: Identifier for the hook being timed.
98+
"""
99+
self._active_timers[hook_name] = time.monotonic()
100+
101+
def stop_timing(self, hook_name: str) -> float:
102+
"""Stop timing and record the duration. Evaluates mode switch.
103+
104+
Args:
105+
hook_name: Identifier for the hook to stop timing.
106+
107+
Returns:
108+
Elapsed time in milliseconds.
109+
110+
Raises:
111+
ValueError: If no active timer for the given hook.
112+
"""
113+
if hook_name not in self._active_timers:
114+
raise ValueError(f"No active timer for: {hook_name}")
115+
116+
elapsed_ms = (time.monotonic() - self._active_timers.pop(hook_name)) * 1000
117+
118+
if hook_name not in self._timings:
119+
self._timings[hook_name] = []
120+
self._timings[hook_name].append(elapsed_ms)
121+
122+
# Keep only the sliding window
123+
if len(self._timings[hook_name]) > self._window_size:
124+
self._timings[hook_name] = self._timings[hook_name][-self._window_size:]
125+
126+
self._evaluate_mode()
127+
return elapsed_ms
128+
129+
def record_timing(self, hook_name: str, elapsed_ms: float) -> None:
130+
"""Record a timing directly (when start/stop is not used).
131+
132+
Args:
133+
hook_name: Identifier for the hook.
134+
elapsed_ms: Elapsed time in milliseconds.
135+
"""
136+
if hook_name not in self._timings:
137+
self._timings[hook_name] = []
138+
self._timings[hook_name].append(elapsed_ms)
139+
140+
if len(self._timings[hook_name]) > self._window_size:
141+
self._timings[hook_name] = self._timings[hook_name][-self._window_size:]
142+
143+
self._evaluate_mode()
144+
145+
def should_skip(self, operation: str) -> bool:
146+
"""Check if an operation should be skipped in current mode.
147+
148+
Args:
149+
operation: Operation name to check.
150+
151+
Returns:
152+
True if the operation should be skipped (lightweight mode
153+
active and operation is heavy).
154+
"""
155+
if not self._lightweight_mode:
156+
return False
157+
return operation in HEAVY_OPERATIONS
158+
159+
def get_status(self) -> Dict[str, Any]:
160+
"""Get current performance monitor status.
161+
162+
Returns:
163+
Dict with mode, threshold, timings summary, and switch count.
164+
"""
165+
timing_summary: Dict[str, Dict[str, float]] = {}
166+
for hook_name, timings in self._timings.items():
167+
if timings:
168+
timing_summary[hook_name] = {
169+
"count": len(timings),
170+
"avg_ms": round(sum(timings) / len(timings), 2),
171+
"max_ms": round(max(timings), 2),
172+
}
173+
return {
174+
"lightweight_mode": self._lightweight_mode,
175+
"timeout_ms": self._timeout_ms,
176+
"switch_count": self._switch_count,
177+
"timings": timing_summary,
178+
}
179+
180+
def _evaluate_mode(self) -> None:
181+
"""Evaluate whether to switch modes based on recent timings.
182+
183+
Switches to lightweight mode if any hook's average recent
184+
execution time exceeds the threshold. Switches back to normal
185+
if all hooks are under 60% of the threshold.
186+
"""
187+
any_over_threshold = False
188+
all_under_recovery = True
189+
recovery_threshold = self._timeout_ms * 0.6
190+
191+
for timings in self._timings.values():
192+
if not timings:
193+
continue
194+
avg = sum(timings) / len(timings)
195+
if avg >= self._timeout_ms:
196+
any_over_threshold = True
197+
if avg >= recovery_threshold:
198+
all_under_recovery = False
199+
200+
if not self._lightweight_mode and any_over_threshold:
201+
self._lightweight_mode = True
202+
self._switch_count += 1
203+
elif self._lightweight_mode and all_under_recovery and not any_over_threshold:
204+
self._lightweight_mode = False
205+
self._switch_count += 1
206+
207+
208+
def get_monitor(config: Optional[Dict[str, Any]] = None) -> AdaptivePerformanceMonitor:
209+
"""Convenience function to get the singleton monitor.
210+
211+
Args:
212+
config: Optional codingbuddy config dict.
213+
214+
Returns:
215+
The AdaptivePerformanceMonitor singleton.
216+
"""
217+
return AdaptivePerformanceMonitor.get_instance(config)
218+
219+
220+
def format_lightweight_notice(language: str = "en") -> str:
221+
"""Format user-facing notice when switching to lightweight mode.
222+
223+
Args:
224+
language: Language code (en, ko, ja, zh, es).
225+
226+
Returns:
227+
Localized notice string.
228+
"""
229+
notices = {
230+
"en": (
231+
"[CodingBuddy] Switched to lightweight mode — "
232+
"hooks were exceeding time threshold. "
233+
"Heavy analysis (conflict prediction, full project scan) "
234+
"will be skipped. Core functions remain active."
235+
),
236+
"ko": (
237+
"[CodingBuddy] 경량 모드로 전환됨 — "
238+
"훅 실행 시간이 임계값을 초과했습니다. "
239+
"무거운 분석(충돌 예측, 전체 프로젝트 스캔)을 건너뜁니다. "
240+
"핵심 기능은 유지됩니다."
241+
),
242+
"ja": (
243+
"[CodingBuddy] 軽量モードに切り替えました — "
244+
"フックの実行時間がしきい値を超えました。"
245+
"重い分析(コンフリクト予測、フルプロジェクトスキャン)は"
246+
"スキップされます。コア機能は維持されます。"
247+
),
248+
"zh": (
249+
"[CodingBuddy] 已切换到轻量模式 — "
250+
"钩子执行时间超过阈值。"
251+
"将跳过重度分析(冲突预测、完整项目扫描)。"
252+
"核心功能保持活跃。"
253+
),
254+
"es": (
255+
"[CodingBuddy] Cambiado a modo ligero — "
256+
"los hooks excedieron el umbral de tiempo. "
257+
"Se omitirá el análisis pesado (predicción de conflictos, "
258+
"escaneo completo del proyecto). Las funciones principales "
259+
"permanecen activas."
260+
),
261+
}
262+
return notices.get(language, notices["en"])
263+
264+
265+
def format_normal_notice(language: str = "en") -> str:
266+
"""Format user-facing notice when returning to normal mode.
267+
268+
Args:
269+
language: Language code (en, ko, ja, zh, es).
270+
271+
Returns:
272+
Localized notice string.
273+
"""
274+
notices = {
275+
"en": (
276+
"[CodingBuddy] Returned to normal mode — "
277+
"hook performance has recovered. "
278+
"Full analysis is now active."
279+
),
280+
"ko": (
281+
"[CodingBuddy] 일반 모드로 복귀 — "
282+
"훅 성능이 회복되었습니다. "
283+
"전체 분석이 다시 활성화됩니다."
284+
),
285+
"ja": (
286+
"[CodingBuddy] 通常モードに復帰 — "
287+
"フックのパフォーマンスが回復しました。"
288+
"完全な分析が再びアクティブです。"
289+
),
290+
"zh": (
291+
"[CodingBuddy] 已返回正常模式 — "
292+
"钩子性能已恢复。"
293+
"完整分析已重新激活。"
294+
),
295+
"es": (
296+
"[CodingBuddy] Vuelto al modo normal — "
297+
"el rendimiento de los hooks se ha recuperado. "
298+
"El análisis completo está activo nuevamente."
299+
),
300+
}
301+
return notices.get(language, notices["en"])

packages/claude-code-plugin/hooks/pre-tool-use.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from safe_main import safe_main
2525
from config import get_config
2626
from agent_status import build_status_message
27+
from adaptive_perf import get_monitor
2728

2829
# Pattern to detect git commit in a command string
2930
_GIT_COMMIT_RE = re.compile(r"\bgit\s+commit\b")
@@ -181,12 +182,17 @@ def _handle(data: dict) -> Optional[dict]:
181182
tool_name = data.get("tool_name", "")
182183
contexts = []
183184

185+
# Initialize adaptive performance monitor (#1002)
186+
perf_config = _get_hook_config()
187+
perf_monitor = get_monitor(perf_config)
188+
184189
# Bash-specific checks
185190
if tool_name == "Bash":
186-
# Check for file changes (#823)
187-
file_change_msg = _check_file_changes(data)
188-
if file_change_msg:
189-
contexts.append(file_change_msg)
191+
# Check for file changes (#823) — skip in lightweight mode (#1002)
192+
if not perf_monitor.should_skip("file_watcher"):
193+
file_change_msg = _check_file_changes(data)
194+
if file_change_msg:
195+
contexts.append(file_change_msg)
190196

191197
command = data.get("tool_input", {}).get("command", "")
192198

packages/claude-code-plugin/hooks/session-start.py

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -542,27 +542,38 @@ def main():
542542

543543
# Step 6: Buddy greeting + project scan + agent recommendations (#968)
544544
# Enhanced with returning session context (#975)
545+
# Adaptive performance mode (#1002): skip heavy scan in lightweight mode
545546
try:
546547
_ensure_lib_path()
547548

548549
from config import get_config as _get_config
549-
from project_scanner import scan_project, get_agent_recommendations
550550
from buddy_renderer import render_session_start
551+
from adaptive_perf import get_monitor, format_lightweight_notice
551552

552553
cwd = os.environ.get("CLAUDE_PROJECT_DIR", str(Path.cwd()))
553554
cfg = _get_config(cwd)
554555
tone = cfg.get("tone", "casual")
555556
language = cfg.get("language", "en")
556557

557-
# Scan project
558-
scan_data = scan_project(cwd)
558+
perf_monitor = get_monitor(cfg)
559559

560-
# Load agent visuals
561-
agents_dir = _find_agents_dir()
562-
agents = load_agent_visuals(agents_dir) if agents_dir else {}
560+
scan_data = {}
561+
recommendations = []
563562

564-
# Generate recommendations
565-
recommendations = get_agent_recommendations(scan_data, agents)
563+
if not perf_monitor.should_skip("full_project_scan"):
564+
from project_scanner import scan_project
565+
perf_monitor.start_timing("project_scan")
566+
scan_data = scan_project(cwd)
567+
elapsed = perf_monitor.stop_timing("project_scan")
568+
569+
if not perf_monitor.should_skip("agent_recommendations"):
570+
agents_dir = _find_agents_dir()
571+
agents = load_agent_visuals(agents_dir) if agents_dir else {}
572+
from project_scanner import get_agent_recommendations
573+
recommendations = get_agent_recommendations(scan_data, agents)
574+
else:
575+
# Lightweight mode: notify user
576+
print(format_lightweight_notice(language), file=sys.stderr)
566577

567578
# Detect returning session (#975)
568579
previous_session = None

0 commit comments

Comments
 (0)