feat: config-driven architecture, install wizard, live runtime switching, usage tracking, auto-failover
Major changes: - Config-driven adapters: all channels (Slack, Discord, Telegram, WebChat, Webhooks) controlled via config.json with enabled flags and token auto-detection, no CLI flags required - Runtime engine field: runtime.engine selects opencode/claude from config - Interactive install script: 8-phase setup wizard with AI runtime detection/installation, token setup, identity file personalization (personality presets), aetheel CLI command, background service (launchd/systemd) - Live runtime switching: /engine, /model, /provider commands hot-swap the AI runtime from chat without restart, changes persisted to config.json - Usage tracking: per-request cost extraction from Claude Code JSON output, cumulative stats via /usage command - Auto-failover: rate limit detection on both runtimes, automatic switch to other engine on quota errors with user notification - Chat commands work without / prefix (Slack intercepts / in channels), commands: engine, model, provider, config, usage, reload, cron, subagents, status, help - /config set for editing config.json from chat with dotted key notation - Security audit saved to docs/security-audit.md - Full command reference in docs/commands.md - Future changes doc with NanoClaw agent teams analysis - Logo added to README and WebChat UI - README fully rewritten with all features documented
This commit is contained in:
283
hooks/hooks.py
Normal file
283
hooks/hooks.py
Normal file
@@ -0,0 +1,283 @@
|
||||
"""
|
||||
Aetheel Hook System
|
||||
===================
|
||||
Event-driven lifecycle hooks inspired by OpenClaw's internal hook system.
|
||||
|
||||
Hooks fire on lifecycle events (gateway startup, session new/reset, agent
|
||||
bootstrap) and let you run custom Python code at those moments. Hooks are
|
||||
discovered from HOOK.md files in the workspace and managed directories.
|
||||
|
||||
Supported events:
|
||||
- gateway:startup — Gateway process starts (after adapters connect)
|
||||
- gateway:shutdown — Gateway process is shutting down
|
||||
- command:new — User starts a fresh session (/new)
|
||||
- command:reload — User reloads config (/reload)
|
||||
- agent:bootstrap — Before workspace files are injected into context
|
||||
- agent:response — After the agent produces a response
|
||||
|
||||
Hook structure:
|
||||
~/.aetheel/workspace/hooks/<name>/HOOK.md (workspace hooks)
|
||||
~/.aetheel/hooks/<name>/HOOK.md (managed hooks)
|
||||
|
||||
HOOK.md format:
|
||||
---
|
||||
name: my-hook
|
||||
description: What this hook does
|
||||
events: [gateway:startup, command:new]
|
||||
enabled: true
|
||||
---
|
||||
# Documentation goes here...
|
||||
|
||||
Handler: hooks/<name>/handler.py with a `handle(event)` function.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Callable
|
||||
|
||||
logger = logging.getLogger("aetheel.hooks")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class HookEvent:
|
||||
"""An event passed to hook handlers."""
|
||||
type: str # e.g. "gateway", "command", "agent"
|
||||
action: str # e.g. "startup", "new", "bootstrap"
|
||||
session_key: str = ""
|
||||
context: dict[str, Any] = field(default_factory=dict)
|
||||
timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
messages: list[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def event_key(self) -> str:
|
||||
return f"{self.type}:{self.action}"
|
||||
|
||||
|
||||
# Callback type: receives a HookEvent
|
||||
HookHandler = Callable[[HookEvent], None]
|
||||
|
||||
|
||||
@dataclass
|
||||
class HookEntry:
|
||||
"""A discovered hook with metadata."""
|
||||
name: str
|
||||
description: str
|
||||
events: list[str] # e.g. ["gateway:startup", "command:new"]
|
||||
enabled: bool = True
|
||||
source: str = "workspace" # "workspace", "managed", or "programmatic"
|
||||
base_dir: str = ""
|
||||
handler_path: str = ""
|
||||
_handler_fn: HookHandler | None = field(default=None, repr=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hook Manager
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class HookManager:
|
||||
"""
|
||||
Discovers, loads, and triggers lifecycle hooks.
|
||||
|
||||
Hooks are discovered from:
|
||||
1. Workspace hooks: <workspace>/hooks/<name>/HOOK.md
|
||||
2. Managed hooks: ~/.aetheel/hooks/<name>/HOOK.md
|
||||
3. Programmatic: registered via register()
|
||||
|
||||
When an event fires, all hooks listening for that event are called
|
||||
in discovery order. Errors in one hook don't prevent others from running.
|
||||
"""
|
||||
|
||||
def __init__(self, workspace_dir: str | None = None):
|
||||
self._hooks: list[HookEntry] = []
|
||||
self._programmatic: dict[str, list[HookHandler]] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._workspace_dir = workspace_dir
|
||||
|
||||
def discover(self) -> list[HookEntry]:
|
||||
"""Discover hooks from workspace and managed directories."""
|
||||
self._hooks = []
|
||||
dirs_to_scan: list[tuple[str, str]] = []
|
||||
|
||||
# Workspace hooks
|
||||
if self._workspace_dir:
|
||||
ws_hooks = os.path.join(self._workspace_dir, "hooks")
|
||||
if os.path.isdir(ws_hooks):
|
||||
dirs_to_scan.append((ws_hooks, "workspace"))
|
||||
|
||||
# Managed hooks (~/.aetheel/hooks/)
|
||||
managed = os.path.expanduser("~/.aetheel/hooks")
|
||||
if os.path.isdir(managed):
|
||||
dirs_to_scan.append((managed, "managed"))
|
||||
|
||||
for hooks_dir, source in dirs_to_scan:
|
||||
for entry_name in sorted(os.listdir(hooks_dir)):
|
||||
hook_dir = os.path.join(hooks_dir, entry_name)
|
||||
if not os.path.isdir(hook_dir):
|
||||
continue
|
||||
hook_md = os.path.join(hook_dir, "HOOK.md")
|
||||
if not os.path.isfile(hook_md):
|
||||
continue
|
||||
try:
|
||||
hook = self._parse_hook(hook_md, source)
|
||||
if hook and hook.enabled:
|
||||
self._hooks.append(hook)
|
||||
logger.info(
|
||||
f"Hook discovered: {hook.name} "
|
||||
f"(events={hook.events}, source={source})"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load hook from {hook_md}: {e}")
|
||||
|
||||
logger.info(f"Hooks discovered: {len(self._hooks)} hook(s)")
|
||||
return list(self._hooks)
|
||||
|
||||
def register(self, event_key: str, handler: HookHandler) -> None:
|
||||
"""Register a programmatic hook handler for an event key."""
|
||||
with self._lock:
|
||||
self._programmatic.setdefault(event_key, []).append(handler)
|
||||
|
||||
def unregister(self, event_key: str, handler: HookHandler) -> None:
|
||||
"""Unregister a programmatic hook handler."""
|
||||
with self._lock:
|
||||
handlers = self._programmatic.get(event_key, [])
|
||||
if handler in handlers:
|
||||
handlers.remove(handler)
|
||||
|
||||
def trigger(self, event: HookEvent) -> list[str]:
|
||||
"""
|
||||
Trigger all hooks listening for this event.
|
||||
|
||||
Returns any messages hooks pushed to event.messages.
|
||||
"""
|
||||
event_key = event.event_key
|
||||
|
||||
# File-based hooks
|
||||
for hook in self._hooks:
|
||||
if event_key in hook.events or event.type in hook.events:
|
||||
self._invoke_hook(hook, event)
|
||||
|
||||
# Programmatic hooks
|
||||
with self._lock:
|
||||
type_handlers = list(self._programmatic.get(event.type, []))
|
||||
specific_handlers = list(self._programmatic.get(event_key, []))
|
||||
|
||||
for handler in type_handlers + specific_handlers:
|
||||
try:
|
||||
handler(event)
|
||||
except Exception as e:
|
||||
logger.error(f"Programmatic hook error [{event_key}]: {e}")
|
||||
|
||||
return list(event.messages)
|
||||
|
||||
def list_hooks(self) -> list[HookEntry]:
|
||||
"""List all discovered hooks."""
|
||||
return list(self._hooks)
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Internal
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
def _invoke_hook(self, hook: HookEntry, event: HookEvent) -> None:
|
||||
"""Invoke a file-based hook's handler."""
|
||||
if hook._handler_fn is None:
|
||||
hook._handler_fn = self._load_handler(hook)
|
||||
if hook._handler_fn is None:
|
||||
return
|
||||
try:
|
||||
hook._handler_fn(event)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Hook error [{hook.name}] on {event.event_key}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
def _load_handler(self, hook: HookEntry) -> HookHandler | None:
|
||||
"""Load a hook's handler.py module and return its handle() function."""
|
||||
handler_path = hook.handler_path
|
||||
if not handler_path or not os.path.isfile(handler_path):
|
||||
logger.debug(f"No handler.py for hook {hook.name}")
|
||||
return None
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
f"hook_{hook.name}", handler_path
|
||||
)
|
||||
if spec is None or spec.loader is None:
|
||||
return None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
handler_fn = getattr(module, "handle", None)
|
||||
if callable(handler_fn):
|
||||
return handler_fn
|
||||
logger.warning(f"Hook {hook.name}: handler.py has no handle() function")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load handler for hook {hook.name}: {e}")
|
||||
return None
|
||||
|
||||
def _parse_hook(self, hook_md_path: str, source: str) -> HookEntry | None:
|
||||
"""Parse a HOOK.md file into a HookEntry."""
|
||||
with open(hook_md_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
frontmatter, _ = self._split_frontmatter(content)
|
||||
if not frontmatter:
|
||||
return None
|
||||
|
||||
name = self._extract_field(frontmatter, "name")
|
||||
if not name:
|
||||
name = os.path.basename(os.path.dirname(hook_md_path))
|
||||
|
||||
description = self._extract_field(frontmatter, "description") or ""
|
||||
events_raw = self._extract_field(frontmatter, "events") or ""
|
||||
events = self._parse_list(events_raw)
|
||||
enabled_raw = self._extract_field(frontmatter, "enabled")
|
||||
enabled = enabled_raw.lower() != "false" if enabled_raw else True
|
||||
|
||||
base_dir = os.path.dirname(hook_md_path)
|
||||
handler_path = os.path.join(base_dir, "handler.py")
|
||||
|
||||
return HookEntry(
|
||||
name=name,
|
||||
description=description,
|
||||
events=events,
|
||||
enabled=enabled,
|
||||
source=source,
|
||||
base_dir=base_dir,
|
||||
handler_path=handler_path,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _split_frontmatter(content: str) -> tuple[str, str]:
|
||||
match = re.match(r"^---\s*\n(.*?)\n---\s*\n(.*)", content, re.DOTALL)
|
||||
if match:
|
||||
return match.group(1), match.group(2)
|
||||
return "", content
|
||||
|
||||
@staticmethod
|
||||
def _extract_field(frontmatter: str, field_name: str) -> str | None:
|
||||
pattern = rf"^{re.escape(field_name)}\s*:\s*(.+)$"
|
||||
match = re.search(pattern, frontmatter, re.MULTILINE)
|
||||
if match:
|
||||
value = match.group(1).strip().strip("'\"")
|
||||
return value
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _parse_list(raw: str) -> list[str]:
|
||||
if not raw:
|
||||
return []
|
||||
raw = raw.strip()
|
||||
if raw.startswith("[") and raw.endswith("]"):
|
||||
raw = raw[1:-1]
|
||||
return [item.strip().strip("'\"") for item in raw.split(",") if item.strip()]
|
||||
Reference in New Issue
Block a user