重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。

同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。

Made-with: Cursor
This commit is contained in:
oliver 2026-04-26 08:34:33 +08:00
parent 4a23b715a2
commit dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions

View file

@ -1,30 +1,101 @@
## `oclaw/hooks`
## `oclaw/runtime/hooks`
Unified Python hooks runtime and hook packages.
Python hooks runtime and bundled hook packages (parity target: OpenClaw `src/hooks`).
### What you get
- **In-process hook bus**: register on `type` or `type:action`, sync/async handlers, isolated failures.
- **Directory discovery**: finds hooks by `HOOK.md + handler.py` (or `index.py`).
- **Config gating**: supports `hooks.internal.enabled` and `hooks.internal.entries.<hookKey>.enabled`.
- **Source precedence**: bundled / managed / workspace collision resolution.
- **Directory discovery**: `HOOK.md` + **one** handler file per hook directory (first match in priority order, see `workspace._handler_candidates`).
- **Config gating**: `hooks.internal.enabled` and `hooks.internal.entries.<hookKey>.enabled`.
- **Source precedence**: bundled / managed / workspace / plugin collision resolution (`policy`).
- **Eligibility**: OS / bins / env / config paths; optional **remote** context from message metadata (`eligibility_from_metadata` + `config.should_include_hook`).
### Handler entry priority
First existing file under the hook directory wins:
`handler.py` → `index.py` → `handler.ts` → `index.ts` → `handler.mts` → `index.mts` → `handler.cts` → `index.cts` → `handler.mjs` → `index.mjs` → `handler.cjs` → `index.cjs` → `handler.sh` → `index.sh` → `handler.bash` → `index.bash`
### Hook layout
Put hooks in any of:
- **Bundled**: `oclaw/hooks/bundled/<hookName>/`
- **Bundled**: shipped with runtime (`runtime_hooks_bundled_root()`)
- **Managed**: `~/.oclaw/hooks/<hookName>/`
- **Workspace**: `<workspace>/hooks/<hookName>/` (explicit opt-in by default)
- **Workspace**: `<workspace>/hooks/<hookName>/`
- **Plugin**: `.openclaw/extensions/<id>/.codex-plugin/plugin.json` → `hooks` paths
- **Extra dirs**: `hooks.internal.load.extraDirs` plus skill-side `.../hooks` dirs merged at runtime init
Each hook directory must contain:
Each hook directory needs `HOOK.md` (YAML frontmatter with `metadata.oclaw.events`) plus one handler file as above.
- `HOOK.md` with YAML frontmatter including `metadata.oclaw.events`
- `handler.py` (or `index.py`) exporting a callable `handle(event)`
### Remote eligibility on inbound messages
Callers (e.g. gateway) may attach JSON metadata:
```json
{
"hookEligibility": {
"remote": {
"platforms": ["linux"],
"binsPresent": ["git", "node"],
"note": "remote agent capabilities"
}
}
}
```
Parsed by `hook_eligibility_from_message_metadata` and passed into `initialize_hooks_runtime(..., eligibility=...)`. **Note:** hook runtime initializes once per process; the first successful init wins (see `hooks_runtime.initialize_hooks_runtime`).
### TS parity matrix (OpenClaw `src/hooks`)
Legend: **Done** / **Partial** / **TODO**
| Area | Status |
|------|--------|
| internal-hooks bus (`register` / `trigger` / `type` + `type:action`) | Done |
| loader (`.py` import + TS/JS/shell runners, `hookMode` / `nodeScript`) | Done |
| path boundary (`handlerPath` under `baseDir`) | Done |
| frontmatter + `metadata.oclaw` | Done |
| invocation + config enable gate | Done |
| policy / source precedence | Done |
| runtime eligibility (`os` / `requires` / env / config) | Done |
| remote eligibility (`platforms` / `hasBin` / `hasAnyBin`) — filter API + gateway metadata wiring | Done |
| package.json `openclaw.hooks` / `oclaw.hooks` | Done |
| plugin hook dirs (`.codex-plugin` + `hooks`) | Done |
| legacy `hooks.internal.handlers` | Done |
| `hooks list/check/info` CLI (`python -m oclaw.runtime.operations hooks …`) | Done |
| `hooks enable` / `hooks disable` (config file patch) | Done |
| install / update hook packs (npm/git; TS `install.ts` / `update.ts`) | Partial (`hooks install` / `hooks update` print deprecation + manual/OpenClaw guidance; no npm/git runner) |
| gmail watcher family | Partial (config gates + ``initialize_hooks_runtime`` → ``start_gmail_watcher_with_logs``; ``gog``/API loop not ported). Set ``OCLAW_SKIP_GMAIL_WATCHER=1`` (or ``OPENCLAW_SKIP_GMAIL_WATCHER``) to no-op. |
| fire-and-forget / message-hook mappers | TODO |
### Minimal self-test
From the repository root (see `tests/conftest.py` for `sys.path` layout):
```bash
python "oclaw/hooks/_selftest.py"
python runtime/hooks/_selftest.py
```
or run hook discovery / parity tests:
```bash
pytest tests/test_oclaw_hooks_bundled_parity.py tests/test_oclaw_hooks_runtime.py -q
```
### Operations CLI (from parent of this repo on `sys.path`, see `tests/conftest.py`)
```bash
python -m oclaw.runtime.operations hooks list
python -m oclaw.runtime.operations hooks list --eligible --verbose
python -m oclaw.runtime.operations hooks check --json
python -m oclaw.runtime.operations hooks info session-memory --workspace /path/to/workspace
python -m oclaw.runtime.operations hooks enable session-memory --workspace /path/to/workspace
python -m oclaw.runtime.operations hooks disable command-logger --workspace /path/to/workspace
python -m oclaw.runtime.operations hooks install ./path-or-npm-spec # deprecated, exit 2 + hints
python -m oclaw.runtime.operations hooks update --dry-run # deprecated, exit 2 + hints
```
Uses the same merged config as the agent runtime (including skill `hooks/` extra dirs via `merge_skill_hook_extra_dirs_into_config`).
**Enable** matches OpenClaw semantics: the hook must satisfy **requirements** (bins/os/env/config) if its config entry were turned on; **plugin** hooks cannot be toggled from this CLI. Writes go to **`OCLAW_CONFIG_PATH`** (optional, relative paths resolved under `PROJECT_ROOT`) or **`oclaw/oclaw.json`** by default.

View file

@ -1,3 +1,13 @@
"""
Public hooks API surface.
Design contract:
- Typed-first internals: loaders/policy/config operate on `HookEntry`.
- Compatibility wrappers: `_compat` helpers accept legacy dict entries.
- External callers should migrate to typed APIs over time; wrappers remain for
incremental rollout and backward compatibility.
"""
from .internal_hooks import (
HookEvent,
HookHandler,
@ -11,6 +21,10 @@ from .internal_hooks import (
)
from .loader import load_internal_hooks
from .config import should_include_hook, should_include_hook_compat
from .policy import resolve_hook_entries_compat
from .hooks_status import build_workspace_hook_status
from .eligibility_from_metadata import hook_eligibility_from_message_metadata
__all__ = [
"HookEvent",
@ -23,5 +37,10 @@ __all__ = [
"trigger_hook",
"create_hook_event",
"load_internal_hooks",
"should_include_hook",
"should_include_hook_compat",
"resolve_hook_entries_compat",
"build_workspace_hook_status",
"hook_eligibility_from_message_metadata",
]

View file

@ -29,9 +29,7 @@ def _candidate_roots(event: Any) -> list[Path]:
repo = Path(__file__).resolve().parents[4]
roots.extend(
[
repo / "oclaw" / "runtime" / "assets" / "agent_workspaces" / "workspace-main",
repo / "oclaw" / "workspace-main",
repo / "oclaw" / "workspace",
repo / "oclaw" / "runtime" / "workspaces" / "main",
repo,
]
)

View file

@ -283,6 +283,22 @@ def handle(event: Any) -> None:
"skip_reason": "memory_mode_store_only",
}
return
manager_gate = ctx.get("need_wiki_inject")
if isinstance(manager_gate, bool) and not manager_gate:
ctx["wiki_inject_meta"] = {
"enabled": False,
"memory_mode": memory_mode,
"skip_reason": "manager_gate_off",
}
return
manager_query = str(ctx.get("wiki_query") or "").strip()
if isinstance(manager_gate, bool) and manager_gate and not manager_query:
ctx["wiki_inject_meta"] = {
"enabled": False,
"memory_mode": memory_mode,
"skip_reason": "manager_wiki_query_missing",
}
return
cfg = _load_config()
entry = _resolve_wiki_entry(cfg)
if not _enabled(entry):
@ -295,7 +311,7 @@ def handle(event: Any) -> None:
wiki_root, max_chars, top_k, ultra_saver_enabled, min_query_chars, require_topic_hint = _resolve_runtime(entry)
topic_rules = _resolve_topic_rules(entry)
query = str(ctx.get("userText") or "").strip()
if ultra_saver_enabled and len(query) < min_query_chars:
if ultra_saver_enabled and not isinstance(manager_gate, bool) and len(query) < min_query_chars:
ctx["wiki_inject_meta"] = {
"enabled": False,
"memory_mode": memory_mode,
@ -305,7 +321,7 @@ def handle(event: Any) -> None:
"query_len": int(len(query)),
}
return
if ultra_saver_enabled and require_topic_hint and not _query_topic_hints(query, topic_rules):
if ultra_saver_enabled and not isinstance(manager_gate, bool) and require_topic_hint and not _query_topic_hints(query, topic_rules):
ctx["wiki_inject_meta"] = {
"enabled": False,
"memory_mode": memory_mode,

173
runtime/hooks/config.py Normal file
View file

@ -0,0 +1,173 @@
from __future__ import annotations
import os
import platform
import shutil
from typing import Any, Dict, Optional
from .hook_manifest_core import HookMetadataSpec, HookRequiresSpec
from .hook_types import HookEligibilityContext, HookEntry, ensure_hook_entry
from .policy import resolve_hook_config, resolve_hook_enable_state
_DEFAULT_CONFIG_VALUES: Dict[str, bool] = {
"browser.enabled": True,
"browser.evaluateEnabled": True,
"workspace.dir": True,
}
def _resolve_config_path(config: Optional[Dict[str, Any]], path_str: str) -> Any:
if not isinstance(config, dict):
return None
cur: Any = config
for segment in str(path_str or "").split("."):
key = segment.strip()
if not key:
continue
if not isinstance(cur, dict):
return None
cur = cur.get(key)
return cur
def _is_config_path_truthy(config: Optional[Dict[str, Any]], path_str: str) -> bool:
key = str(path_str or "").strip()
if not key:
return False
value = _resolve_config_path(config, key)
if value is None and key in _DEFAULT_CONFIG_VALUES:
return _DEFAULT_CONFIG_VALUES[key]
return bool(value)
def _has_binary(bin_name: str) -> bool:
return bool(shutil.which(str(bin_name or "").strip()))
def _resolve_runtime_platform() -> str:
return str(platform.system() or "").strip().lower()
def _normalize_os_name(os_name: str) -> str:
v = str(os_name or "").strip().lower()
if v in {"mac", "macos", "darwin"}:
return "darwin"
if v in {"win", "windows"}:
return "windows"
if v in {"linux"}:
return "linux"
return v
def _hook_os_gate_satisfied(*, hook_os: tuple[str, ...], remote_platforms: list[str]) -> bool:
"""
Match OpenClaw ``evaluateRuntimeEligibility`` OS rule:
If the hook declares ``os``, pass when the **local** runtime matches *or* when any
advertised **remote** platform matches one of the hook's supported OS names.
"""
os_list = [str(x).strip() for x in hook_os if str(x or "").strip()]
if not os_list:
return True
cur = _normalize_os_name(_resolve_runtime_platform())
hook_names = {_normalize_os_name(x) for x in os_list}
if cur in hook_names:
return True
rem = [_normalize_os_name(x) for x in remote_platforms if str(x or "").strip()]
return any(r in hook_names for r in rem)
def _parse_requires_obj(raw: Any) -> HookRequiresSpec:
row = raw if isinstance(raw, dict) else {}
bins = tuple(str(x).strip() for x in list(row.get("bins") or []) if str(x).strip())
any_bins = tuple(str(x).strip() for x in list(row.get("anyBins") or []) if str(x).strip())
env = tuple(str(x).strip() for x in list(row.get("env") or []) if str(x).strip())
config = tuple(str(x).strip() for x in list(row.get("config") or []) if str(x).strip())
return HookRequiresSpec(bins=bins, any_bins=any_bins, env=env, config=config)
def _parse_metadata_obj(raw: Any) -> HookMetadataSpec:
row = raw if isinstance(raw, dict) else {}
node_script: bool | None
if "nodeScript" in row:
node_script = bool(row.get("nodeScript"))
else:
node_script = None
hm = row.get("hookMode")
hook_mode = str(hm).strip() if isinstance(hm, str) and str(hm).strip() else None
return HookMetadataSpec(
events=tuple(str(x).strip() for x in list(row.get("events") or []) if str(x).strip()),
always=bool(row["always"]) if isinstance(row.get("always"), bool) else None,
emoji=str(row.get("emoji")).strip() if isinstance(row.get("emoji"), str) else None,
homepage=str(row.get("homepage")).strip() if isinstance(row.get("homepage"), str) else None,
hook_key=str(row.get("hookKey")).strip() if isinstance(row.get("hookKey"), str) else None,
export=str(row.get("export")).strip() if isinstance(row.get("export"), str) else None,
os=tuple(str(x).strip() for x in list(row.get("os") or []) if str(x).strip()),
requires=_parse_requires_obj(row.get("requires")),
install=(),
hook_mode=hook_mode,
node_script=node_script,
)
def should_include_hook(
*, entry: HookEntry, config: Optional[Dict[str, Any]], eligibility: Optional[HookEligibilityContext] = None
) -> bool:
metadata = _parse_metadata_obj(entry.metadata)
hook_name = str(entry.hook.name or "").strip()
hook_key = str(metadata.hook_key or hook_name).strip() or hook_name
hook_cfg = resolve_hook_config(config, hook_key)
if not resolve_hook_enable_state(entry, config).get("enabled"):
return False
remote = (eligibility or {}).get("remote") if isinstance(eligibility, dict) else None
remote_platforms: list[str] = []
if isinstance(remote, dict):
rp = remote.get("platforms")
if isinstance(rp, list):
remote_platforms = [str(x).strip() for x in rp if str(x or "").strip()]
if not _hook_os_gate_satisfied(hook_os=metadata.os, remote_platforms=remote_platforms):
return False
req = metadata.requires or HookRequiresSpec()
bins = list(req.bins)
for b in bins:
if remote and callable(remote.get("hasBin")):
if not bool(remote["hasBin"](b)):
return False
continue
if not _has_binary(b):
return False
any_bins = list(req.any_bins)
if any_bins:
if remote and callable(remote.get("hasAnyBin")):
if not bool(remote["hasAnyBin"](any_bins)):
return False
elif not any(_has_binary(b) for b in any_bins):
return False
env_req = list(req.env)
hook_env = hook_cfg.get("env") if isinstance(hook_cfg, dict) and isinstance(hook_cfg.get("env"), dict) else {}
for env_name in env_req:
if os.getenv(env_name) or hook_env.get(env_name):
continue
return False
config_req = list(req.config)
for cfg_path in config_req:
if not _is_config_path_truthy(config, cfg_path):
return False
return True
def should_include_hook_compat(
*,
entry: HookEntry | Dict[str, Any],
config: Optional[Dict[str, Any]],
eligibility: Optional[HookEligibilityContext] = None,
) -> bool:
return should_include_hook(entry=ensure_hook_entry(entry), config=config, eligibility=eligibility)

View file

@ -0,0 +1,72 @@
from __future__ import annotations
from typing import Any
from .hook_types import HookEligibilityContext, HookRemoteEligibility
def hook_eligibility_from_message_metadata(metadata: dict[str, Any] | None) -> HookEligibilityContext | None:
"""
Build ``HookEligibilityContext`` from inbound message metadata (e.g. gateway / API).
Expected shape (JSON-friendly):
.. code-block:: json
{
"hookEligibility": {
"remote": {
"platforms": ["darwin", "linux", "windows"],
"binsPresent": ["git", "node"],
"note": "from remote agent / bridge"
}
}
}
- ``platforms``: when non-empty, overrides hook ``metadata.oclaw.os`` for eligibility (same as TS).
- ``binsPresent``: when non-empty, supplies ``hasBin`` / ``hasAnyBin`` predicates so bin checks
can reflect a **remote** execution environment instead of ``shutil.which`` on this machine.
"""
if not isinstance(metadata, dict):
return None
block = metadata.get("hookEligibility")
if not isinstance(block, dict):
return None
rem = block.get("remote")
if not isinstance(rem, dict):
return None
platforms_raw = rem.get("platforms")
platforms: list[str] = []
if isinstance(platforms_raw, list):
platforms = [str(x).strip().lower() for x in platforms_raw if str(x or "").strip()]
bins_raw = rem.get("binsPresent")
bins_present: set[str] = set()
if isinstance(bins_raw, list):
bins_present = {str(x).strip() for x in bins_raw if str(x or "").strip()}
note_raw = rem.get("note")
note = str(note_raw).strip() if isinstance(note_raw, str) else ""
if not platforms and not bins_present and not note:
return None
r: HookRemoteEligibility = {}
if platforms:
r["platforms"] = platforms
if note:
r["note"] = note
if bins_present:
def has_bin(name: str) -> bool:
return str(name or "").strip() in bins_present
def has_any_bin(names: list[Any]) -> bool:
return any(has_bin(str(x or "")) for x in (names or []))
r["hasBin"] = has_bin
r["hasAnyBin"] = has_any_bin
return {"remote": r}

View file

@ -1,9 +1,10 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict, Optional, Tuple
from typing import Any, Dict, Optional
import yaml
from .hook_manifest_core import parse_hook_manifest
@dataclass(frozen=True, slots=True)
@ -53,15 +54,14 @@ def parse_frontmatter(markdown: str) -> ParsedHookFrontmatter:
def resolve_oclaw_metadata(frontmatter: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""
Oclaw embeds metadata as a JSON-like object under key 'metadata' -> 'oclaw'
(or sometimes as already-parsed dict). We normalize to a dict if present.
"""
meta = frontmatter.get("metadata")
if not isinstance(meta, dict):
return None
oc = meta.get("oclaw")
return oc if isinstance(oc, dict) else None
parsed = parse_hook_manifest(frontmatter=frontmatter, default_name="hook")
out = parsed.metadata.as_dict()
return out if out else None
def resolve_hook_invocation_policy(frontmatter: Dict[str, Any]) -> Dict[str, bool]:
parsed = parse_hook_manifest(frontmatter=frontmatter, default_name="hook")
return {"enabled": bool(parsed.invocation_enabled)}
def resolve_hook_key(name: str, entry: Dict[str, Any]) -> str:

View file

@ -0,0 +1,44 @@
from __future__ import annotations
import shutil
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True)
class GmailWatcherResult:
started: bool
reason: str = ""
def start_gmail_watcher(cfg: dict[str, Any] | None) -> GmailWatcherResult:
"""
Gmail watcher gate (OpenClaw ``startGmailWatcher`` parity, subset).
Full ``gog`` + Gmail API + renew loop is not ported in Python yet; this
function encodes the same **configuration preconditions** so lifecycle
logging matches expectations.
"""
if not isinstance(cfg, dict):
return GmailWatcherResult(started=False, reason="no gmail account configured")
hooks = cfg.get("hooks")
if not isinstance(hooks, dict):
return GmailWatcherResult(started=False, reason="hooks not enabled")
# OpenClaw top-level ``hooks.enabled`` (when absent, treat as enabled).
if hooks.get("enabled") is False:
return GmailWatcherResult(started=False, reason="hooks not enabled")
internal = hooks.get("internal") if isinstance(hooks.get("internal"), dict) else {}
if internal.get("enabled") is False:
return GmailWatcherResult(started=False, reason="hooks not enabled")
gmail = hooks.get("gmail")
if not isinstance(gmail, dict) or not str(gmail.get("account") or "").strip():
return GmailWatcherResult(started=False, reason="no gmail account configured")
if not shutil.which("gog"):
return GmailWatcherResult(started=False, reason="gog binary not found")
return GmailWatcherResult(started=False, reason="gmail watcher runtime not implemented (Python)")

View file

@ -0,0 +1,53 @@
from __future__ import annotations
import os
from typing import Any, Callable, Protocol
from .gmail_watcher import GmailWatcherResult, start_gmail_watcher
class GmailWatcherLog(Protocol):
def info(self, msg: str) -> None: ...
def warn(self, msg: str) -> None: ...
def error(self, msg: str) -> None: ...
def _is_truthy_env(value: str | None) -> bool:
return str(value or "").strip().lower() in {"1", "true", "yes", "on"}
def _skip_gmail_watcher_env() -> bool:
for key in ("OCLAW_SKIP_GMAIL_WATCHER", "OPENCLAW_SKIP_GMAIL_WATCHER"):
if _is_truthy_env(os.getenv(key)):
return True
return False
def start_gmail_watcher_with_logs(
*,
cfg: dict[str, Any] | None,
log: GmailWatcherLog,
on_skipped: Callable[[], None] | None = None,
starter: Callable[[dict[str, Any] | None], GmailWatcherResult] = start_gmail_watcher,
) -> None:
"""Skip entirely when ``OCLAW_SKIP_GMAIL_WATCHER`` or ``OPENCLAW_SKIP_GMAIL_WATCHER`` is truthy."""
if _skip_gmail_watcher_env():
if on_skipped:
on_skipped()
return
try:
res = starter(cfg)
if bool(res.started):
log.info("gmail watcher started")
return
reason = str(res.reason or "").strip()
if reason and reason not in {
"hooks not enabled",
"no gmail account configured",
"gmail watcher runtime not implemented (Python)",
}:
log.warn(f"gmail watcher not started: {reason}")
except Exception as exc:
log.error(f"gmail watcher failed to start: {exc}")

View file

@ -0,0 +1,195 @@
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Any
@dataclass(frozen=True)
class HookInstallSpec:
kind: str
id: str | None = None
label: str | None = None
package: str | None = None
repository: str | None = None
bins: tuple[str, ...] = ()
def as_dict(self) -> dict[str, Any]:
out: dict[str, Any] = {"kind": self.kind}
if self.id:
out["id"] = self.id
if self.label:
out["label"] = self.label
if self.package:
out["package"] = self.package
if self.repository:
out["repository"] = self.repository
if self.bins:
out["bins"] = list(self.bins)
return out
@dataclass(frozen=True)
class HookRequiresSpec:
bins: tuple[str, ...] = ()
any_bins: tuple[str, ...] = ()
env: tuple[str, ...] = ()
config: tuple[str, ...] = ()
def as_dict(self) -> dict[str, Any]:
out: dict[str, Any] = {}
if self.bins:
out["bins"] = list(self.bins)
if self.any_bins:
out["anyBins"] = list(self.any_bins)
if self.env:
out["env"] = list(self.env)
if self.config:
out["config"] = list(self.config)
return out
@dataclass(frozen=True)
class HookMetadataSpec:
events: tuple[str, ...] = ()
always: bool | None = None
emoji: str | None = None
homepage: str | None = None
hook_key: str | None = None
export: str | None = None
os: tuple[str, ...] = ()
requires: HookRequiresSpec | None = None
install: tuple[HookInstallSpec, ...] = ()
hook_mode: str | None = None
node_script: bool | None = None
def as_dict(self) -> dict[str, Any]:
out: dict[str, Any] = {"events": list(self.events)}
if self.always is not None:
out["always"] = bool(self.always)
if self.emoji:
out["emoji"] = self.emoji
if self.homepage:
out["homepage"] = self.homepage
if self.hook_key:
out["hookKey"] = self.hook_key
if self.export:
out["export"] = self.export
if self.os:
out["os"] = list(self.os)
if self.requires:
req = self.requires.as_dict()
if req:
out["requires"] = req
if self.install:
out["install"] = [row.as_dict() for row in self.install]
if self.hook_mode is not None:
out["hookMode"] = str(self.hook_mode)
if self.node_script is not None:
out["nodeScript"] = bool(self.node_script)
return out
@dataclass(frozen=True)
class ParsedHookManifest:
name: str
description: str
invocation_enabled: bool
metadata: HookMetadataSpec = field(default_factory=HookMetadataSpec)
def _read_str(value: Any) -> str | None:
if isinstance(value, str):
s = value.strip()
return s if s else None
return None
def _normalize_str_list(value: Any) -> tuple[str, ...]:
if not isinstance(value, list):
return ()
out: list[str] = []
for row in value:
s = _read_str(row)
if s:
out.append(s)
return tuple(out)
def _parse_install_spec(row: Any) -> HookInstallSpec | None:
if not isinstance(row, dict):
return None
kind = (_read_str(row.get("kind")) or "").lower()
if kind not in {"bundled", "npm", "git"}:
return None
return HookInstallSpec(
kind=kind,
id=_read_str(row.get("id")),
label=_read_str(row.get("label")),
package=_read_str(row.get("package")),
repository=_read_str(row.get("repository")),
bins=_normalize_str_list(row.get("bins")),
)
def _resolve_raw_oclaw_metadata(frontmatter: dict[str, Any]) -> dict[str, Any]:
meta = frontmatter.get("metadata")
if isinstance(meta, str):
try:
parsed = json.loads(meta)
meta = parsed if isinstance(parsed, dict) else None
except Exception:
meta = None
if not isinstance(meta, dict):
return {}
oc = meta.get("oclaw")
return oc if isinstance(oc, dict) else {}
def parse_hook_manifest(*, frontmatter: dict[str, Any], default_name: str) -> ParsedHookManifest:
name = _read_str(frontmatter.get("name")) or default_name
description = _read_str(frontmatter.get("description")) or ""
enabled_raw = frontmatter.get("enabled")
invocation_enabled = bool(enabled_raw) if isinstance(enabled_raw, bool) else True
oc = _resolve_raw_oclaw_metadata(frontmatter)
requires_obj = oc.get("requires") if isinstance(oc.get("requires"), dict) else {}
requires = HookRequiresSpec(
bins=_normalize_str_list(requires_obj.get("bins")),
any_bins=_normalize_str_list(requires_obj.get("anyBins")),
env=_normalize_str_list(requires_obj.get("env")),
config=_normalize_str_list(requires_obj.get("config")),
)
install_rows: list[HookInstallSpec] = []
if isinstance(oc.get("install"), list):
for row in oc["install"]:
parsed = _parse_install_spec(row)
if parsed:
install_rows.append(parsed)
node_script: bool | None
if "nodeScript" in oc:
node_script = bool(oc.get("nodeScript"))
else:
node_script = None
metadata = HookMetadataSpec(
events=_normalize_str_list(oc.get("events")),
always=bool(oc["always"]) if isinstance(oc.get("always"), bool) else None,
emoji=_read_str(oc.get("emoji")),
homepage=_read_str(oc.get("homepage")),
hook_key=_read_str(oc.get("hookKey")),
export=_read_str(oc.get("export")),
os=_normalize_str_list(oc.get("os")),
requires=requires if requires.as_dict() else None,
install=tuple(install_rows),
hook_mode=_read_str(oc.get("hookMode")),
node_script=node_script,
)
return ParsedHookManifest(
name=name,
description=description,
invocation_enabled=invocation_enabled,
metadata=metadata,
)

104
runtime/hooks/hook_types.py Normal file
View file

@ -0,0 +1,104 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Literal, TypedDict
HookSource = Literal["oclaw-bundled", "oclaw-plugin", "oclaw-managed", "oclaw-workspace"]
_HOOK_SOURCES: tuple[HookSource, ...] = (
"oclaw-bundled",
"oclaw-plugin",
"oclaw-managed",
"oclaw-workspace",
)
class HookEntryDict(TypedDict, total=False):
hook: dict[str, Any]
frontmatter: dict[str, Any]
metadata: dict[str, Any]
invocation: dict[str, Any]
class HookRemoteEligibility(TypedDict, total=False):
platforms: list[str]
hasBin: Any
hasAnyBin: Any
note: str
class HookEligibilityContext(TypedDict, total=False):
remote: HookRemoteEligibility
@dataclass(frozen=True)
class HookRef:
name: str
description: str
source: HookSource
pluginId: str | None
filePath: str
baseDir: str
handlerPath: str
@dataclass(frozen=True)
class HookInvocation:
enabled: bool = True
@dataclass(frozen=True)
class HookEntry:
hook: HookRef
frontmatter: dict[str, Any]
metadata: dict[str, Any]
invocation: HookInvocation
def as_dict(self) -> dict[str, Any]:
return {
"hook": {
"name": self.hook.name,
"description": self.hook.description,
"source": self.hook.source,
"pluginId": self.hook.pluginId,
"filePath": self.hook.filePath,
"baseDir": self.hook.baseDir,
"handlerPath": self.hook.handlerPath,
},
"frontmatter": dict(self.frontmatter),
"metadata": dict(self.metadata),
"invocation": {"enabled": bool(self.invocation.enabled)},
}
def ensure_entry_dict(entry: HookEntry | HookEntryDict) -> dict[str, Any]:
return entry.as_dict() if isinstance(entry, HookEntry) else dict(entry)
def _normalize_source(value: Any) -> HookSource:
s = str(value or "").strip()
if s in _HOOK_SOURCES:
return s
return "oclaw-managed"
def ensure_hook_entry(entry: HookEntry | HookEntryDict) -> HookEntry:
if isinstance(entry, HookEntry):
return entry
row = dict(entry or {})
hook_raw = row.get("hook") if isinstance(row.get("hook"), dict) else {}
inv_raw = row.get("invocation") if isinstance(row.get("invocation"), dict) else {}
return HookEntry(
hook=HookRef(
name=str(hook_raw.get("name") or ""),
description=str(hook_raw.get("description") or ""),
source=_normalize_source(hook_raw.get("source")),
pluginId=(str(hook_raw.get("pluginId")) if hook_raw.get("pluginId") is not None else None),
filePath=str(hook_raw.get("filePath") or ""),
baseDir=str(hook_raw.get("baseDir") or ""),
handlerPath=str(hook_raw.get("handlerPath") or ""),
),
frontmatter=dict(row.get("frontmatter") or {}),
metadata=dict(row.get("metadata") or {}),
invocation=HookInvocation(enabled=bool(inv_raw.get("enabled")) if "enabled" in inv_raw else True),
)

View file

@ -0,0 +1,125 @@
from __future__ import annotations
import shutil
from typing import Any
from .config import should_include_hook
from .hook_types import HookEligibilityContext
from .policy import resolve_hook_enable_state
from .workspace import load_workspace_hook_entries
def _select_install_suggestion(
*, install_options: list[dict[str, Any]], missing_bins: list[str]
) -> dict[str, Any] | None:
if not install_options:
return None
if not missing_bins:
return dict(install_options[0])
missing = set(missing_bins)
best: tuple[int, int, dict[str, Any]] | None = None
for idx, row in enumerate(install_options):
bins = [str(x).strip() for x in list(row.get("bins") or []) if str(x).strip()]
overlap = len(missing.intersection(set(bins)))
# Sort by overlap desc, then idx asc (stable preference for first declared option).
score = (overlap, -idx)
if best is None or score > (best[0], best[1]):
best = (score[0], score[1], row)
return dict(best[2]) if best else dict(install_options[0])
def build_workspace_hook_status(
workspace_dir: str,
*,
config: dict[str, Any] | None = None,
managed_hooks_dir: str | None = None,
bundled_hooks_dir: str | None = None,
extra_dirs: list[str] | None = None,
eligibility: HookEligibilityContext | None = None,
) -> dict[str, Any]:
entries = load_workspace_hook_entries(
workspace_dir,
config=config,
managed_hooks_dir=managed_hooks_dir,
bundled_hooks_dir=bundled_hooks_dir,
extra_dirs=extra_dirs,
)
rows: list[dict[str, Any]] = []
summary = {
"discovered_total": 0,
"enabled_by_config_total": 0,
"eligible_total": 0,
"loadable_total": 0,
"missing_bins_total": 0,
"blocked_by_reason": {},
}
for entry in entries:
summary["discovered_total"] += 1
state = resolve_hook_enable_state(entry, config)
enabled = bool(state.get("enabled"))
if enabled:
summary["enabled_by_config_total"] += 1
eligible = should_include_hook(entry=entry, config=config, eligibility=eligibility)
if eligible:
summary["eligible_total"] += 1
loadable = enabled and eligible
if loadable:
summary["loadable_total"] += 1
reason = str(state.get("reason") or "")
if not loadable:
blocked = reason or ("missing requirements" if enabled else "disabled")
summary["blocked_by_reason"][blocked] = int(summary["blocked_by_reason"].get(blocked) or 0) + 1
md = entry.metadata or {}
req = md.get("requires") if isinstance(md.get("requires"), dict) else {}
required_bins = [str(x).strip() for x in list(req.get("bins") or []) if str(x).strip()]
missing_bins = [b for b in required_bins if not shutil.which(b)]
if missing_bins:
summary["missing_bins_total"] += len(missing_bins)
install_rows = md.get("install") if isinstance(md.get("install"), list) else []
install_options: list[dict[str, Any]] = []
for idx, row in enumerate(install_rows):
if not isinstance(row, dict):
continue
kind = str(row.get("kind") or "").strip()
if not kind:
continue
install_options.append(
{
"id": str(row.get("id") or f"{kind}-{idx}"),
"kind": kind,
"label": str(row.get("label") or ""),
"bins": [str(x).strip() for x in list(row.get("bins") or []) if str(x).strip()],
}
)
rows.append(
{
"name": entry.hook.name,
"source": entry.hook.source,
"plugin_id": entry.hook.pluginId,
"hook_key": str((entry.metadata or {}).get("hookKey") or entry.hook.name),
"events": list((entry.metadata or {}).get("events") or []),
"enabled_by_config": enabled,
"eligible": bool(eligible),
"loadable": bool(loadable),
"blocked_reason": "" if loadable else (reason or "missing requirements"),
"required_bins": required_bins,
"missing_bins": missing_bins,
"install_options": install_options,
"install_suggestion": _select_install_suggestion(
install_options=install_options,
missing_bins=missing_bins,
),
}
)
return {
"workspace_dir": workspace_dir,
"summary": summary,
"hooks": rows,
}

View file

@ -74,7 +74,10 @@ def create_hook_event(
session_key: str,
context: Optional[Dict[str, Any]] = None,
) -> HookEvent:
return HookEvent(type=event_type, action=action, sessionKey=session_key, context=context or {})
# Must not use `context or {}` — a caller-supplied empty dict is falsy and would be replaced.
return HookEvent(
type=event_type, action=action, sessionKey=session_key, context={} if context is None else context
)
async def trigger_hook(event: HookEvent) -> None:

View file

@ -0,0 +1,56 @@
/**
* node js_hook_runner.mjs <absolute-handler> [exportName]
* stdin: JSON { type, action, sessionKey, context, messages?, timestamp? }
* stdout: JSON { "context"?: object }
* Loads .mjs / .cjs (and ESM .js if passed) with dynamic import or createRequire.
*/
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { pathToFileURL } from "node:url";
const handlerPath = process.argv[2];
const exportName = (process.argv[3] || "default").trim();
if (!handlerPath) {
console.error("oclaw: js_hook_runner: missing handler path");
process.exit(2);
}
const abs = path.resolve(handlerPath);
const raw = readFileSync(0, "utf-8");
const data = JSON.parse(raw);
const event = {
type: data.type,
action: data.action,
sessionKey: data.sessionKey,
context: typeof data.context === "object" && data.context ? data.context : {},
messages: Array.isArray(data.messages) ? data.messages : [],
timestamp: data.timestamp,
};
const ext = path.extname(abs).toLowerCase();
let mod;
if (ext === ".cjs") {
const require = createRequire(import.meta.url);
mod = require(abs);
} else {
mod = await import(pathToFileURL(abs).href);
}
const modRec = mod && typeof mod === "object" ? mod : {};
let fn;
if (exportName && exportName !== "default" && typeof modRec[exportName] === "function") {
fn = modRec[exportName];
} else {
fn = modRec.default ?? modRec.handle ?? modRec.handler;
}
if (typeof fn !== "function") {
console.error("oclaw: js_hook_runner: no function export in", abs, "export", exportName);
process.exit(3);
}
const r = fn(event);
if (r && typeof r.then === "function") {
await r;
}
process.stdout.write(JSON.stringify({ context: event.context }));

View file

@ -1,13 +1,31 @@
from __future__ import annotations
import importlib.util
import os
from pathlib import Path
from typing import Any, Dict, List, Optional
from typing import Any, Optional
from .config import should_include_hook_compat
from .hook_types import HookEligibilityContext, HookEntry, ensure_hook_entry
from .internal_hooks import HookHandler, register_hook, unregister_hook
from .policy import resolve_hook_enable_state, resolve_hook_config
from .workspace import HookEntry, load_workspace_hook_entries
from .script_handlers import build_script_hook_handler
from .workspace import load_workspace_hook_entries
_loaded_hook_registrations: list[tuple[str, HookHandler]] = []
def _reset_loaded_internal_hooks() -> None:
while _loaded_hook_registrations:
event_key, handler = _loaded_hook_registrations.pop()
unregister_hook(event_key, handler)
def _is_within_base(handler_path: str, base_dir: str) -> bool:
try:
Path(handler_path).resolve().relative_to(Path(base_dir).resolve())
return True
except Exception:
return False
def _load_module_from_path(module_path: str, unique_key: str) -> Optional[object]:
@ -26,10 +44,43 @@ def _load_module_from_path(module_path: str, unique_key: str) -> Optional[object
return mod
def _resolve_legacy_handlers(config: dict[str, Any]) -> list[dict[str, str]]:
rows = (((config.get("hooks") or {}).get("internal") or {}).get("handlers"))
if not isinstance(rows, list):
return []
out: list[dict[str, str]] = []
for row in rows:
if not isinstance(row, dict):
continue
event = str(row.get("event") or "").strip()
module = str(row.get("module") or "").strip()
export = str(row.get("export") or "default").strip() or "default"
if not event or not module:
continue
out.append({"event": event, "module": module, "export": export})
return out
def _resolve_workspace_module_path(*, workspace_dir: str, raw_module: str) -> Optional[str]:
if not raw_module or raw_module.startswith("/") or raw_module.startswith("\\"):
return None
ws = Path(workspace_dir).resolve()
module_path = (ws / raw_module).resolve()
try:
module_path.relative_to(ws)
except Exception:
return None
if not module_path.exists() or not module_path.is_file():
return None
return str(module_path)
def _resolve_handler(mod: object, export_name: str) -> Optional[HookHandler]:
handler = getattr(mod, export_name, None)
if callable(handler):
return handler # type: ignore[return-value]
normalized = str(export_name or "").strip()
if normalized and normalized != "default":
handler = getattr(mod, normalized, None)
if callable(handler):
return handler # type: ignore[return-value]
# fallback: common names
for candidate in ("handle", "handler", "main"):
h = getattr(mod, candidate, None)
@ -39,29 +90,41 @@ def _resolve_handler(mod: object, export_name: str) -> Optional[HookHandler]:
def load_internal_hooks(
config: Dict[str, Any],
config: dict[str, Any],
workspace_dir: str,
*,
managed_hooks_dir: Optional[str] = None,
bundled_hooks_dir: Optional[str] = None,
eligibility: Optional[HookEligibilityContext] = None,
) -> int:
"""
Discover python hooks and register them into the in-process registry.
Discover hooks and register them into the in-process registry.
Hook file layout:
Handler files (first match wins), see ``workspace._handler_candidates`` for the full list.
- ``.py``: in-process import (``handle`` / ``handler`` / ``main`` or ``metadata.oclaw.export``)
- ``.ts`` / ``.mts`` / ``.cts`` (default): ``tsx`` + ``ts_hook_runner.ts`` (import ``default`` / ``handle`` or ``export``)
- ``.mjs`` / ``.cjs`` (default): ``node`` + ``js_hook_runner.mjs`` (``import`` / ``require`` + same exports)
- ``.sh`` / ``.bash``: subprocess: JSON on stdin, optional JSON on stdout (``context`` merge)
- ``metadata.oclaw.hookMode: "script"`` (or legacy ``nodeScript: true``): run ``.ts`` / ``.mjs`` / ``.cjs`` as
**stdin/stdout scripts** (no import runner) — use ``node`` for JS, ``tsx`` for TS
Hook layout (unchanged):
<hookDir>/HOOK.md (YAML frontmatter with oclaw metadata)
<hookDir>/handler.py (or index.py)
Metadata fields used:
metadata.oclaw.events: ["type", "type:action", ...]
metadata.oclaw.export: handler function name (default: "default" -> we map to "handle")
"""
_reset_loaded_internal_hooks()
# Hook system is on by default; only skip when explicitly disabled.
if (((config.get("hooks") or {}).get("internal") or {}).get("enabled")) is False:
return 0
entries = load_workspace_hook_entries(
workspace_dir,
config=config,
managed_hooks_dir=managed_hooks_dir,
bundled_hooks_dir=bundled_hooks_dir,
extra_dirs=(((config.get("hooks") or {}).get("internal") or {}).get("load") or {}).get("extraDirs"),
@ -69,35 +132,74 @@ def load_internal_hooks(
loaded = 0
for idx, entry in enumerate(entries):
state = resolve_hook_enable_state(entry, config)
if not state.get("enabled"):
if not should_include_hook_compat(entry=entry, config=config, eligibility=eligibility):
continue
entry_obj = ensure_hook_entry(entry)
md = entry.get("metadata") or {}
md = entry_obj.metadata or {}
events = md.get("events") if isinstance(md, dict) else None
if not isinstance(events, list) or not events:
continue
hook = entry.get("hook") or {}
handler_path = hook.get("handlerPath")
handler_path = entry_obj.hook.handlerPath
base_dir = entry_obj.hook.baseDir
if not isinstance(handler_path, str) or not handler_path:
continue
if not isinstance(base_dir, str) or not base_dir.strip():
continue
if not _is_within_base(handler_path, base_dir):
continue
export_name = md.get("export") if isinstance(md, dict) else None
if not isinstance(export_name, str) or not export_name.strip():
export_name = "handle"
export_name = "default"
mod = _load_module_from_path(handler_path, unique_key=str(idx))
if mod is None:
continue
handler = _resolve_handler(mod, export_name)
suffix = Path(handler_path).suffix.lower()
handler: Optional[HookHandler] = None
if suffix in {".py"}:
mod = _load_module_from_path(handler_path, unique_key=str(idx))
if mod is None:
continue
handler = _resolve_handler(mod, export_name)
else:
oclaw_dict: dict[str, Any] = {}
if isinstance(md, dict):
for key in ("hookMode", "nodeScript"):
if key in md:
oclaw_dict[key] = md[key]
handler = build_script_hook_handler(
handler_path=handler_path,
base_dir=base_dir,
suffix=suffix,
export_name=export_name,
oclaw=oclaw_dict,
)
if handler is None:
continue
for event_key in events:
if isinstance(event_key, str) and event_key.strip():
register_hook(event_key.strip(), handler)
ek = event_key.strip()
register_hook(ek, handler)
_loaded_hook_registrations.append((ek, handler))
loaded += 1
# Legacy config handlers (hooks.internal.handlers)
for j, row in enumerate(_resolve_legacy_handlers(config)):
safe_path = _resolve_workspace_module_path(workspace_dir=workspace_dir, raw_module=row["module"])
if not safe_path:
continue
mod = _load_module_from_path(safe_path, unique_key=f"legacy_{j}")
if mod is None:
continue
handler = _resolve_handler(mod, row.get("export") or "default")
if handler is None:
continue
ev = row.get("event") or ""
if not ev:
continue
register_hook(ev, handler)
_loaded_hook_registrations.append((ev, handler))
loaded += 1
return loaded

View file

@ -0,0 +1,39 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
from oclaw.runtime.skills import discover_workspace_skill_manifests
def merge_skill_hook_extra_dirs_into_config(cfg: dict[str, Any]) -> dict[str, Any]:
"""
Append skill package ``<skillDir>/hooks`` directories to ``hooks.internal.load.extraDirs``.
Matches ``initialize_hooks_runtime`` so CLI / status reports see the same hook set as the agent.
"""
resolved_cfg = dict(cfg)
extra_dirs: list[str] = []
try:
for m in discover_workspace_skill_manifests():
d = (Path(str(m.skill_dir or "")) / "hooks").resolve()
if d.exists() and d.is_dir():
extra_dirs.append(str(d))
except Exception:
extra_dirs = []
if not extra_dirs:
return resolved_cfg
hooks_cfg = dict((resolved_cfg.get("hooks") or {})) if isinstance(resolved_cfg.get("hooks"), dict) else {}
internal = dict((hooks_cfg.get("internal") or {})) if isinstance(hooks_cfg.get("internal"), dict) else {}
load = dict((internal.get("load") or {})) if isinstance(internal.get("load"), dict) else {}
prev = load.get("extraDirs")
merged: list[str] = []
if isinstance(prev, list):
merged.extend([str(x) for x in prev if str(x).strip()])
merged.extend([x for x in extra_dirs if x and x not in set(merged)])
load["extraDirs"] = merged
internal["load"] = load
hooks_cfg["internal"] = internal
resolved_cfg["hooks"] = hooks_cfg
return resolved_cfg

View file

@ -4,8 +4,7 @@ from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Literal, Optional, Sequence, Tuple
from .frontmatter import resolve_hook_key
HookSource = Literal["oclaw-bundled", "oclaw-plugin", "oclaw-managed", "oclaw-workspace"]
from .hook_types import HookEntry, HookSource, ensure_entry_dict, ensure_hook_entry
@dataclass(frozen=True, slots=True)
@ -63,22 +62,27 @@ def resolve_hook_config(config: Optional[Dict[str, Any]], hook_key: str) -> Opti
return entry if isinstance(entry, dict) else None
def resolve_hook_enable_state(entry: Dict[str, Any], config: Optional[Dict[str, Any]]) -> Dict[str, Any]:
def resolve_hook_enable_state(entry: HookEntry | Dict[str, Any], config: Optional[Dict[str, Any]]) -> Dict[str, Any]:
"""
Returns { enabled: bool, reason?: str }
"""
hook = entry.get("hook") if isinstance(entry, dict) else None
entry_dict = ensure_entry_dict(entry)
hook = entry_dict.get("hook") if isinstance(entry_dict, dict) else None
name = (hook or {}).get("name") if isinstance(hook, dict) else None
source = (hook or {}).get("source") if isinstance(hook, dict) else None
if not isinstance(name, str) or not isinstance(source, str):
return {"enabled": False, "reason": "invalid hook entry"}
hook_key = resolve_hook_key(name, entry)
hook_key = resolve_hook_key(name, entry_dict)
hook_cfg = resolve_hook_config(config, hook_key)
invocation = entry_dict.get("invocation") if isinstance(entry_dict.get("invocation"), dict) else {}
if source == "oclaw-plugin":
return {"enabled": True}
if invocation.get("enabled") is False:
return {"enabled": False, "reason": "disabled by hook invocation policy"}
if isinstance(hook_cfg, dict) and hook_cfg.get("enabled") is False:
return {"enabled": False, "reason": "disabled in config"}
@ -89,9 +93,11 @@ def resolve_hook_enable_state(entry: Dict[str, Any], config: Optional[Dict[str,
return {"enabled": True}
def _can_override(candidate: Dict[str, Any], existing: Dict[str, Any]) -> bool:
c_source = ((candidate.get("hook") or {}).get("source")) if isinstance(candidate, dict) else None
e_source = ((existing.get("hook") or {}).get("source")) if isinstance(existing, dict) else None
def _can_override(candidate: HookEntry | Dict[str, Any], existing: HookEntry | Dict[str, Any]) -> bool:
c_dict = ensure_entry_dict(candidate)
e_dict = ensure_entry_dict(existing)
c_source = ((c_dict.get("hook") or {}).get("source")) if isinstance(c_dict, dict) else None
e_source = ((e_dict.get("hook") or {}).get("source")) if isinstance(e_dict, dict) else None
if c_source not in HOOK_SOURCE_POLICIES or e_source not in HOOK_SOURCE_POLICIES:
return False
c_pol = get_hook_source_policy(c_source) # type: ignore[arg-type]
@ -100,26 +106,41 @@ def _can_override(candidate: Dict[str, Any], existing: Dict[str, Any]) -> bool:
def resolve_hook_entries(
entries: Sequence[Dict[str, Any]],
entries: Sequence[HookEntry | Dict[str, Any]],
on_collision_ignored: Optional[Callable[[Dict[str, Any]], None]] = None,
) -> List[Dict[str, Any]]:
) -> List[HookEntry]:
ordered = sorted(
list(enumerate(entries)),
key=lambda x: (get_hook_source_policy(x[1]["hook"]["source"]).precedence, x[0]), # type: ignore[index]
key=lambda x: (
get_hook_source_policy(
str((ensure_entry_dict(x[1]).get("hook") or {}).get("source") or "oclaw-managed") # type: ignore[arg-type]
).precedence,
x[0],
),
)
merged: Dict[str, Dict[str, Any]] = {}
merged: Dict[str, HookEntry] = {}
for _, entry in ordered:
name = entry.get("hook", {}).get("name")
name = ensure_entry_dict(entry).get("hook", {}).get("name")
if not isinstance(name, str):
continue
existing = merged.get(name)
if not existing:
merged[name] = entry
merged[name] = ensure_hook_entry(entry)
continue
if _can_override(entry, existing):
merged[name] = entry
merged[name] = ensure_hook_entry(entry)
continue
if on_collision_ignored:
on_collision_ignored({"name": name, "kept": existing, "ignored": entry})
on_collision_ignored(
{"name": name, "kept": ensure_entry_dict(existing), "ignored": ensure_entry_dict(entry)}
)
return list(merged.values())
def resolve_hook_entries_compat(
entries: Sequence[HookEntry | Dict[str, Any]],
on_collision_ignored: Optional[Callable[[Dict[str, Any]], None]] = None,
) -> List[Dict[str, Any]]:
resolved = resolve_hook_entries(entries, on_collision_ignored=on_collision_ignored)
return [ensure_entry_dict(e) for e in resolved]

View file

@ -0,0 +1,247 @@
from __future__ import annotations
import asyncio
import json
import logging
import os
import shutil
from pathlib import Path
from typing import Any, Optional
from .internal_hooks import HookEvent, HookHandler
log = logging.getLogger("oclaw.hooks")
# Max wall-clock for external hook child processes
_HOOK_SUBPROCESS_TIMEOUT_S = 120.0
def _runner_ts_path() -> Path:
return Path(__file__).resolve().parent / "ts_hook_runner.ts"
def _runner_js_path() -> Path:
return Path(__file__).resolve().parent / "js_hook_runner.mjs"
def _is_script_mode(oclaw: dict[str, Any] | None) -> bool:
o = oclaw or {}
if o.get("hookMode") == "script":
return True
if o.get("nodeScript") is True:
return True
return False
def _event_payload(event: HookEvent) -> dict[str, Any]:
return {
"type": event.type,
"action": event.action,
"sessionKey": event.sessionKey,
"context": dict(event.context) if isinstance(event.context, dict) else {},
"messages": list(getattr(event, "messages", []) or []),
"timestamp": event.timestamp.isoformat() if getattr(event, "timestamp", None) else None,
}
def _merge_stdout_into_context(event: HookEvent, raw: str) -> None:
text = (raw or "").strip()
if not text:
return
try:
out = json.loads(text)
except Exception:
log.warning("Hook subprocess stdout is not valid JSON: %r", text[:200])
return
if not isinstance(out, dict):
return
ctx = out.get("context")
if isinstance(ctx, dict) and isinstance(event.context, dict):
event.context.update(ctx)
def _sh_command(script: Path) -> list[str] | None:
p = str(script)
if not script.is_file():
return None
if os.name == "nt":
bash = shutil.which("bash")
if bash:
return [bash, p]
wsl = shutil.which("wsl")
if wsl:
return [wsl, "bash", p]
log.warning("Hook .sh on Windows needs bash in PATH (Git for Windows) or wsl. Skipping %s", p)
return None
try:
if script.stat().st_mode & 0o111 and os.access(p, os.X_OK):
return [p]
except OSError:
pass
sh = shutil.which("sh") or "/bin/sh"
return [sh, p]
def _tsx_invocation() -> str | None:
return shutil.which("tsx") or None
def _ts_command(*, script: Path, export_name: str) -> list[str] | None:
runner = _runner_ts_path()
if not runner.is_file():
log.error("oclaw: missing ts hook runner: %s", runner)
return None
hp = str(script.resolve())
rts = str(runner.resolve())
ex = export_name.strip() or "default"
tx = _tsx_invocation()
if tx:
return [tx, rts, hp, ex]
npx = shutil.which("npx")
if npx:
return [npx, "--yes", "tsx", rts, hp, ex]
log.warning("Hook .ts needs `tsx` or `npx` (for `npx tsx`) on PATH. Skipping %s", hp)
return None
def _ts_script_command(*, script: Path) -> list[str] | None:
"""Run .ts as a free script: stdin JSON / stdout JSON (no import runner)."""
hp = str(script.resolve())
tx = _tsx_invocation()
if tx:
return [tx, hp]
npx = shutil.which("npx")
if npx:
return [npx, "--yes", "tsx", hp]
log.warning("Hook .ts in script mode needs `tsx` or `npx` on PATH. Skipping %s", hp)
return None
def _node_path() -> str | None:
return shutil.which("node") or None
def _js_module_command(*, script: Path, export_name: str) -> list[str] | None:
node = _node_path()
if not node:
log.warning("Hook .mjs / .cjs needs `node` on PATH. Skipping %s", script)
return None
runner = _runner_js_path()
if not runner.is_file():
log.error("oclaw: missing js hook runner: %s", runner)
return None
hp = str(script.resolve())
rjs = str(runner.resolve())
ex = export_name.strip() or "default"
return [node, rjs, hp, ex]
def _js_script_command(*, script: Path) -> list[str] | None:
node = _node_path()
if not node:
return None
return [node, str(script.resolve())]
async def _run_cmd_handler(
*,
cmd: list[str],
event: HookEvent,
base_dir: str,
script: Path,
log_label: str,
) -> None:
data = json.dumps(_event_payload(event), default=str)
env = {**os.environ, "OCLAW_HOOK_DIR": str(Path(base_dir).resolve()), "OCLAW_HOOK_HANDLER": str(script.resolve())}
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=str(Path(base_dir).resolve()),
env=env,
)
out_b, err_b = await asyncio.wait_for(
proc.communicate(input=data.encode("utf-8")),
timeout=_HOOK_SUBPROCESS_TIMEOUT_S,
)
except asyncio.TimeoutError:
log.error("Hook %s timed out after %ss: %s", log_label, int(_HOOK_SUBPROCESS_TIMEOUT_S), script)
return
except Exception:
log.exception("Hook %s failed to spawn: %s", log_label, script)
return
if proc.returncode != 0:
log.error(
"Hook %s exit %s: %s",
log_label,
proc.returncode,
err_b.decode("utf-8", errors="replace")[:4000],
)
if log.isEnabledFor(logging.DEBUG):
log.debug("Hook %s full stderr: %s", log_label, err_b)
return
out_t = out_b.decode("utf-8", errors="replace")
if not (out_t or "").strip() and err_b:
log.warning(
"Hook %s empty stdout, stderr: %s",
log_label,
err_b.decode("utf-8", errors="replace")[:2000],
)
_merge_stdout_into_context(event, out_t)
def _build_cmd_handler(
cmd: list[str] | None,
*,
base_dir: str,
script: Path,
log_label: str,
) -> Optional[HookHandler]:
if not cmd:
return None
async def _handler(event: HookEvent) -> None:
await _run_cmd_handler(cmd=cmd, event=event, base_dir=base_dir, script=script, log_label=log_label)
return _handler
def build_script_hook_handler(
*,
handler_path: str,
base_dir: str,
suffix: str,
export_name: str,
oclaw: dict[str, Any] | None = None,
) -> Optional[HookHandler]:
p = Path(handler_path)
if not p.is_file():
return None
sfx = (suffix or "").lower()
o = oclaw or {}
script_mode = _is_script_mode(o)
# Shell: always JSON stdin/stdout; never use TS/JS import runners
if sfx in {".sh", ".bash"}:
sc = _sh_command(p)
return _build_cmd_handler(sc, base_dir=base_dir, script=p, log_label="sh")
if script_mode:
if sfx in {".ts", ".mts", ".cts"}:
cmd = _ts_script_command(script=p)
return _build_cmd_handler(cmd, base_dir=base_dir, script=p, log_label="ts:script")
if sfx in {".mjs", ".cjs"}:
cmd = _js_script_command(script=p)
return _build_cmd_handler(cmd, base_dir=base_dir, script=p, log_label="js:script")
# Module / import path (default for ts and js)
if sfx in {".ts", ".mts", ".cts"}:
cmd = _ts_command(script=p, export_name=export_name)
return _build_cmd_handler(cmd, base_dir=base_dir, script=p, log_label="ts:module")
if sfx in {".mjs", ".cjs"}:
cmd = _js_module_command(script=p, export_name=export_name)
return _build_cmd_handler(cmd, base_dir=base_dir, script=p, log_label="js:module")
return None

View file

@ -0,0 +1,45 @@
/**
* Invoked as: npx --yes tsx ts_hook_runner.ts <absolute-handler.ts> [exportName]
* stdin: JSON { type, action, sessionKey, context, messages?, timestamp? }
* stdout: JSON { "context"?: object } merged into the hook event in Python
*/
import { readFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
const handlerPath = process.argv[2];
const exportName = (process.argv[3] || "default").trim();
if (!handlerPath) {
console.error("oclaw: ts_hook_runner: missing handler path");
process.exit(2);
}
const raw = readFileSync(0, "utf-8");
const data = JSON.parse(raw) as Record<string, unknown>;
const event = {
type: data.type,
action: data.action,
sessionKey: data.sessionKey,
context: (typeof data.context === "object" && data.context) ? (data.context as Record<string, unknown>) : {},
messages: Array.isArray(data.messages) ? data.messages : [],
timestamp: data.timestamp,
};
const mod: Record<string, unknown> = (await import(pathToFileURL(handlerPath).href)) as Record<string, unknown>;
let fn: ((ev: unknown) => unknown) | undefined;
if (exportName && exportName !== "default" && mod[exportName] && typeof mod[exportName] === "function") {
fn = mod[exportName] as (ev: unknown) => unknown;
} else {
fn = (mod.default ?? mod.handle ?? mod.handler) as (ev: unknown) => unknown;
}
if (typeof fn !== "function") {
console.error("oclaw: ts_hook_runner: no function export in", handlerPath, "export", exportName);
process.exit(3);
}
const r = fn(event);
if (r && typeof (r as { then?: unknown }).then === "function") {
await (r as Promise<unknown>);
}
process.stdout.write(
JSON.stringify({ context: event.context }, (_k, v) => (typeof v === "bigint" ? v.toString() : v), 0),
);

View file

@ -0,0 +1,81 @@
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any
from oclaw.platform.config.paths import PROJECT_ROOT
def resolve_hooks_config_storage_path() -> Path:
"""
Path used for persistent ``hooks.internal.entries`` edits.
Matches ``resolve_runtime_config`` file resolution: ``OCLAW_CONFIG_PATH`` (optional
relative to ``PROJECT_ROOT``), else ``<PROJECT_ROOT>/oclaw/oclaw.json``.
"""
raw = str(os.getenv("OCLAW_CONFIG_PATH") or "").strip()
if raw:
p = Path(raw).expanduser()
if not p.is_absolute():
p = (Path(PROJECT_ROOT) / p).resolve()
return p
return (Path(PROJECT_ROOT) / "oclaw" / "oclaw.json").resolve()
def load_storage_config_document() -> dict[str, Any]:
p = resolve_hooks_config_storage_path()
if not p.is_file():
return {}
try:
raw = p.read_text(encoding="utf-8")
obj = json.loads(raw)
return dict(obj) if isinstance(obj, dict) else {}
except Exception:
return {}
def save_storage_config_document(doc: dict[str, Any]) -> None:
p = resolve_hooks_config_storage_path()
p.parent.mkdir(parents=True, exist_ok=True)
tmp = p.with_suffix(p.suffix + ".tmp")
tmp.write_text(json.dumps(doc, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
tmp.replace(p)
def apply_hook_entry_enabled(
doc: dict[str, Any],
hook_key: str,
enabled: bool,
*,
ensure_internal_hooks_enabled: bool = False,
) -> None:
"""Mutate *doc* in place (shallow structure for ``hooks.internal`` only)."""
key = str(hook_key or "").strip()
if not key:
raise ValueError("hook_key is empty")
hooks = doc.setdefault("hooks", {})
if not isinstance(hooks, dict):
doc["hooks"] = {}
hooks = doc["hooks"]
internal = hooks.setdefault("internal", {})
if not isinstance(internal, dict):
hooks["internal"] = {}
internal = hooks["internal"]
if ensure_internal_hooks_enabled and enabled:
internal["enabled"] = True
entries = internal.setdefault("entries", {})
if not isinstance(entries, dict):
internal["entries"] = {}
entries = internal["entries"]
row = entries.get(key)
if not isinstance(row, dict):
entries[key] = {}
row = entries[key]
row["enabled"] = bool(enabled)

View file

@ -2,15 +2,13 @@ from __future__ import annotations
import json
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, Iterable, List, Literal, Optional, Sequence, Tuple
from typing import Any, List, Optional, Sequence, Tuple
from .frontmatter import parse_frontmatter, resolve_oclaw_metadata
from .policy import HookSource, resolve_hook_entries
HookEntry = Dict[str, Any]
from .frontmatter import parse_frontmatter
from .hook_manifest_core import parse_hook_manifest
from .hook_types import HookEntry, HookInvocation, HookRef, HookSource
from .policy import resolve_hook_entries
def _read_text(path: Path) -> Optional[str]:
@ -28,7 +26,132 @@ def _safe_is_dir(p: Path) -> bool:
def _handler_candidates() -> Tuple[str, ...]:
return ("handler.py", "index.py")
# One file per hook dir. Order: py → TypeScript (runner) → JS (node runner) → shell.
return (
"handler.py",
"index.py",
"handler.ts",
"index.ts",
"handler.mts",
"index.mts",
"handler.cts",
"index.cts",
"handler.mjs",
"index.mjs",
"handler.cjs",
"index.cjs",
"handler.sh",
"index.sh",
"handler.bash",
"index.bash",
)
def _resolve_contained_dir(base_dir: Path, target_dir: str) -> Optional[Path]:
try:
resolved = (base_dir / target_dir).resolve()
resolved.relative_to(base_dir.resolve())
return resolved
except Exception:
return None
def _parse_package_hook_paths(package_dir: Path) -> List[str]:
"""
Support package.json manifest declarations:
- { "openclaw": { "hooks": [...] } }
- { "oclaw": { "hooks": [...] } }
"""
manifest = package_dir / "package.json"
raw = _read_text(manifest)
if raw is None:
return []
try:
obj = json.loads(raw)
except Exception:
return []
if not isinstance(obj, dict):
return []
out: List[str] = []
for key in ("openclaw", "oclaw"):
row = obj.get(key)
if not isinstance(row, dict):
continue
hooks = row.get("hooks")
if not isinstance(hooks, list):
continue
for it in hooks:
s = str(it or "").strip()
if s:
out.append(s)
if out:
break
return out
def _resolve_plugin_hook_dirs(*, workspace_dir: str, config: Optional[dict[str, Any]]) -> List[tuple[str, str]]:
"""
Lightweight Python parity for OpenClaw plugin hook discovery.
Discover plugin bundles under:
<workspace>/.openclaw/extensions/<plugin-id>/.codex-plugin/plugin.json
and read `hooks` from that manifest (string path or list[str]).
"""
ws = Path(workspace_dir).resolve()
ext_root = ws / ".openclaw" / "extensions"
if not ext_root.exists() or not ext_root.is_dir():
return []
enabled_entries = (
(((config or {}).get("plugins") or {}).get("entries") or {})
if isinstance((((config or {}).get("plugins") or {}).get("entries") or {}), dict)
else {}
)
out: List[tuple[str, str]] = []
seen: set[str] = set()
for plugin_root in ext_root.iterdir():
if not plugin_root.is_dir():
continue
plugin_id = plugin_root.name
state = enabled_entries.get(plugin_id) if isinstance(enabled_entries, dict) else None
if isinstance(state, dict) and state.get("enabled") is False:
continue
manifest_path = plugin_root / ".codex-plugin" / "plugin.json"
raw = _read_text(manifest_path)
if raw is None:
continue
try:
manifest = json.loads(raw)
except Exception:
continue
if not isinstance(manifest, dict):
continue
hooks_value = manifest.get("hooks")
hook_paths: List[str] = []
if isinstance(hooks_value, str):
s = hooks_value.strip()
if s:
hook_paths.append(s)
elif isinstance(hooks_value, list):
for row in hooks_value:
s = str(row or "").strip()
if s:
hook_paths.append(s)
if not hook_paths:
continue
for rel in hook_paths:
resolved = _resolve_contained_dir(plugin_root, rel)
if resolved is None:
continue
key = str(resolved)
if key in seen:
continue
seen.add(key)
out.append((key, plugin_id))
return out
def _load_hook_from_dir(hook_dir: Path, source: HookSource, plugin_id: Optional[str] = None) -> Optional[HookEntry]:
@ -39,12 +162,9 @@ def _load_hook_from_dir(hook_dir: Path, source: HookSource, plugin_id: Optional[
parsed = parse_frontmatter(content)
fm = parsed.frontmatter
name = fm.get("name") or hook_dir.name
if not isinstance(name, str) or not name.strip():
name = hook_dir.name
description = fm.get("description") or ""
if not isinstance(description, str):
description = ""
manifest = parse_hook_manifest(frontmatter=fm, default_name=hook_dir.name)
name = manifest.name
description = manifest.description
handler_path: Optional[Path] = None
for candidate in _handler_candidates():
@ -55,21 +175,20 @@ def _load_hook_from_dir(hook_dir: Path, source: HookSource, plugin_id: Optional[
if handler_path is None:
return None
metadata = resolve_oclaw_metadata(fm) or {}
return {
"hook": {
"name": name,
"description": description,
"source": source,
"pluginId": plugin_id,
"filePath": str(hook_md),
"baseDir": str(hook_dir.resolve()),
"handlerPath": str(handler_path.resolve()),
},
"frontmatter": fm,
"metadata": metadata,
}
return HookEntry(
hook=HookRef(
name=name,
description=description,
source=source,
pluginId=plugin_id,
filePath=str(hook_md),
baseDir=str(hook_dir.resolve()),
handlerPath=str(handler_path.resolve()),
),
frontmatter=dict(fm),
metadata=manifest.metadata.as_dict(),
invocation=HookInvocation(enabled=bool(manifest.invocation_enabled)),
)
def load_hook_entries_from_dir(dir_path: str, source: HookSource, plugin_id: Optional[str] = None) -> List[HookEntry]:
@ -81,6 +200,17 @@ def load_hook_entries_from_dir(dir_path: str, source: HookSource, plugin_id: Opt
for child in base.iterdir():
if not child.is_dir():
continue
package_hook_paths = _parse_package_hook_paths(child)
if package_hook_paths:
for rel in package_hook_paths:
hook_dir = _resolve_contained_dir(child, rel)
if hook_dir is None:
continue
entry = _load_hook_from_dir(hook_dir, source=source, plugin_id=plugin_id)
if entry:
out.append(entry)
continue
entry = _load_hook_from_dir(child, source=source, plugin_id=plugin_id)
if entry:
out.append(entry)
@ -92,6 +222,7 @@ def load_hook_entries_from_dir(dir_path: str, source: HookSource, plugin_id: Opt
def discover_workspace_hook_entries(
workspace_dir: str,
*,
config: Optional[dict[str, Any]] = None,
managed_hooks_dir: Optional[str] = None,
bundled_hooks_dir: Optional[str] = None,
extra_dirs: Optional[Sequence[str]] = None,
@ -118,6 +249,9 @@ def discover_workspace_hook_entries(
if bundled_hooks_dir:
entries.extend(load_hook_entries_from_dir(bundled_hooks_dir, source="oclaw-bundled"))
for plugin_dir, plugin_id in _resolve_plugin_hook_dirs(workspace_dir=workspace_dir, config=config):
entries.extend(load_hook_entries_from_dir(plugin_dir, source="oclaw-plugin", plugin_id=plugin_id))
entries.extend(load_hook_entries_from_dir(str(managed), source="oclaw-managed"))
entries.extend(load_hook_entries_from_dir(str(workspace_hooks), source="oclaw-workspace"))
return entries
@ -126,12 +260,14 @@ def discover_workspace_hook_entries(
def load_workspace_hook_entries(
workspace_dir: str,
*,
config: Optional[dict[str, Any]] = None,
managed_hooks_dir: Optional[str] = None,
bundled_hooks_dir: Optional[str] = None,
extra_dirs: Optional[Sequence[str]] = None,
) -> List[HookEntry]:
discovered = discover_workspace_hook_entries(
workspace_dir,
config=config,
managed_hooks_dir=managed_hooks_dir,
bundled_hooks_dir=bundled_hooks_dir,
extra_dirs=extra_dirs,