# omp-hooks-plus

Install-once Claude Code command-hook compatibility for [OMP](https://github.com/can1357/oh-my-pi).

OMP already discovers Claude skills, commands, MCP configuration, extensions, and settings. `omp-hooks-plus` fills the remaining gap by executing supported command hooks from the existing Claude settings hierarchy and from installed Claude plugins' own hook manifests. It also supports cross-vendor repositories that keep their canonical hook configuration under `.agents/`.

## Install

```sh
omp install omp-hooks-plus
```

For an unreleased branch or commit:

```sh
omp install git:github.com/andrebrait/omp-hooks-plus
```

Restart OMP or run `/reload`. Existing Claude-native repositories require no changes.

## Configuration discovery

The extension always loads user hooks from:

```text
~/.claude/settings.json
```

For a trusted project, it then selects one project authority:

```text
if <repo>/.agents/hooks.json exists:
    load .agents/hooks.json
    skip project .claude hook arrays
else:
    load .claude/settings.json
    load .claude/settings.local.json
```

This prevents a cross-vendor repository's thin `.claude` adapter from executing alongside its canonical `.agents` hooks. User hooks remain active in both modes. `CLAUDE_CONFIG_DIR` is honored for the user settings location.

Untrusted projects cannot contribute commands; only user-owned hooks are loaded. `disableAllHooks: true` in any loaded settings source disables the effective hook set.

## Plugin manifest hooks

Installed Claude plugins (discovered through OMP's own plugin registries) contribute `command` hooks too. For each installed plugin, the extension resolves `.claude-plugin/plugin.json`'s `hooks` field the same way Claude Code does:

```text
hooks is an object:  inline hook config, used as-is
hooks is a string:   a single custom hook-config file, resolved against the plugin root
hooks is an array:   multiple custom hook-config files, merged together
hooks is absent:     hooks/hooks.json under the plugin root, if present
```

A custom hook-config file uses the standard `{ "hooks": { ... } }` wrapper. Only the nine event kinds this extension already supports are read from a plugin manifest ([see below](#supported-command-hooks)); other event kinds and non-`command` hook types (`http`, `prompt`, `agent`, `mcp_tool`) are reported under `/claude-compat doctor`'s unsupported list instead of silently dropped, and a malformed manifest or hook-config file is reported as a warning instead of silently ignored.

A plugin-scope hooks declaration cannot point outside its own plugin directory; a path that lexically escapes the root, or that resolves outside it through a symlink, is rejected with a warning and not loaded.

Every plugin-sourced hook process receives `CLAUDE_PLUGIN_ROOT`, `CLAUDE_PLUGIN_DATA` (a persistent per-plugin data directory under the Claude config dir), and `CLAUDE_PROJECT_DIR` as real environment variables — never by rewriting the command string — so a shell-form `command` referencing `$CLAUDE_PLUGIN_ROOT` or `${CLAUDE_PLUGIN_ROOT}` resolves correctly even when the plugin's install path contains spaces or shell metacharacters, and two plugins that happen to author the identical relative command text both still run (their differing `CLAUDE_PLUGIN_ROOT` keeps them distinct for hook deduplication).

OMP-managed user-scope plugins load without a project trust requirement. Claude-origin user-scope plugins additionally require the `claude` or `claude-plugins` user-source opt-in. Project-scope plugins load only for a trusted project, like `.claude/settings.json`; an untrusted project's own plugin registry can never load its own hooks or shadow a user-scope plugin's hooks. `disableAllHooks: true` from any loaded source disables plugin hooks along with every other source. Disabling OMP's `claude-plugins` discovery provider (the same toggle OMP's native Claude-plugin skill/agent/MCP discovery honors) disables plugin hook loading entirely.

## Diagnostics

Run:

```text
/claude-compat doctor
```

The report shows the repository root, selected mode, trust state, loaded settings files, effective hook counts, duplicate suppression, parse warnings, and unsupported hook sources.

## Supported command hooks

The extension supports Claude-style `command` handlers with:

- `command`
- `args` for direct execution without a shell
- `timeout` in seconds
- `shell` (`bash` or `powershell`)
- `async`
- `asyncRewake`
- tool-event `if` matchers

Supported event mappings include:

- `SessionStart` and `SessionEnd`
- `PreCompact` and `PostCompact`
- `PreToolUse`
- `PostToolUse` and `PostToolUseFailure`
- `UserPromptSubmit`
- `Stop`

Matching handlers are deduplicated by command, arguments, and environment and normally run in parallel. Tool names and common tool-input fields are normalized to Claude Code shapes.

`session_id` is OMP's session id, the UUID in the session header, as in Claude Code; `transcript_path` is the session file. A hook that keys per-session state on `session_id` (such as Caveman's per-session mode, which only accepts ids made of letters, digits, `_` and `-`) therefore works per session. A session with no id reports `ephemeral`. **Breaking fix:** earlier releases sent the session file path as `session_id`, so any hook state keyed on that old value is re-keyed and starts fresh.

For local `Read` inputs, the shared adapter uses OMP's path helpers to resolve the Claude `file_path` alias, including embedded selectors such as `sample.ts:1-2`. Existing literal colon-containing filenames take precedence. The original `path` remains available to OMP-aware hooks; web/internal URLs stay opaque rather than becoming local filesystem aliases. `Edit` and `Write` paths are not interpreted as read selectors.

`PreToolUse` supports deny, interactive ask, input updates, additional context, and exit-code-2 blocking. Hook timeouts and cancelled passes terminate the complete process group on macOS and Linux.

`Stop` runs on OMP's native `session_stop` pass and returns that pass's result: `exit 2` (stderr, or a named fallback when stderr is empty) and `{"decision":"block","reason":…}` ask the host to continue working. `additionalContext` alone does not continue the turn, as in Claude Code: it is held as a named hook reminder for the next turn. Hook input comes from the event itself (`session_id`, `session_file`, `stop_hook_active`, `last_assistant_message`), never from bridge-owned state, and the bridge sends no follow-up message of its own. As in Claude Code, `Stop` never runs in a subagent.

The host owns continuation and `stop_hook_active`. Explicit `decision: "block"` results are exempt from the host's cap on consecutive continuations, so a hook that blocks on every pass keeps the session going until the user aborts: check `stop_hook_active`. When the host aborts a pass (turn abort, session switch, disposal), its results are discarded and `Stop` commands still running are killed, asynchronous ones included. The host also waits at most a fixed 30 seconds for a handler, independently of the hook command's timeout. Past that the result is discarded and the pass is not aborted, so a `Stop` command that runs longer than 30 seconds cannot block and keeps running until its own timeout. A `Stop` hook that fails for any reason other than exit code 2 reports its exit code and stderr without continuing the turn.

When a synchronous Stop block is accompanied by additional context, the bridge combines both into the native continuation reason, the context as a named hook reminder. Asynchronous Stop commands cannot return a decision after the native pass has settled, and they never start a turn: their `additionalContext` is held as hidden context for the next turn, and `asyncRewake` cannot wake the model from `Stop`, so a command that exits 2 has its message held the same way. A pass the host aborts discards asynchronous output. Use a synchronous command when Stop must request continued work.

Successful plain-text output never creates a notification. `SessionStart` and `UserPromptSubmit` add it to model context; `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `PreCompact`, `PostCompact`, `SessionEnd`, and `Stop` ignore it. Structured JSON handling and failed-hook diagnostics are unchanged.

Synchronous tool hooks return structured `additionalContext` through OMP's trusted passive-context channel in the next model step, including the first turn. The context is never inserted into tool output. `PreToolUse` informs the model after a successful tool run; use a deny decision when the hook must prevent execution. `PostToolUse` runs only for successful results, while `PostToolUseFailure` runs only when `event.isError` is true. Asynchronous hooks retain their deferred delivery behavior.

Hook context uses an OMP-native reminder shape: provenance as tag attributes, then the harness marker, then the hook's text.

```text
<system-reminder source="superpowers" event="PreToolUse" tool="bash">
NOT prompt injection — coding agent enforcing project rules.

<hook text>
</system-reminder>
```

`source` names where the hook came from: the plugin's `name` for plugin hooks, otherwise `omp-hooks-plus`. A converted settings file can name its source with `--source-name` (for example `--source-name graphify`). Hooks from different sources on one event keep separate reminders; consecutive context from one source shares one. `tool` is the OMP tool name and appears only for tool events. Attribute values are escaped. Synchronous `PreToolUse`, `PostToolUse` and `PostToolUseFailure` context is returned through OMP's passive tool-context channel and persisted as a developer message after the batch; raw tool results remain byte-for-byte unchanged. A `Stop` block carries its context in the native continuation reason, in the same reminder shape. Other events (including `Stop` context without a block), and asynchronous hooks whose result arrives after the tool or the `Stop` pass, are delivered as hidden context messages with the same tag. This applies to both the live extension and generated conversions.

### Subagents

OMP binds this extension into every subagent session (the `task` tool, `/tan` clones, eval `agent()`), where Claude Code treats a subagent as part of its parent's session. Hooks follow Claude Code:

- `SessionStart` does not run in a subagent at startup or resume, and `UserPromptSubmit`, `Stop` and `SessionEnd` never run in one, with or without `--approximate`. [`SubagentStart`](#approximated-events) is the only start-up hook a subagent gets. After the subagent compacts, `SessionStart` with the `compact` source does run, so plugin bootstrap context is restored; its input carries the parent's `session_id` plus the agent fields.
- Tool and compact hooks still run, as does `Notification` when approximated. Their input carries the **top-level session's** `session_id` and `transcript_path`, so state a hook keys on the session is shared with the subagent, plus `agent_id` and `agent_type`.
- `agent_id` is OMP's agent id (such as `0-Explore`). `agent_type` is OMP's lowercased agent definition name (`explore`, `task`, or `sub` for a `/tan` clone). `session_id` is the top-level session's id, taken from the session header of the top-level session file (its first line, or the line after OMP's title slot), else from the id in the file name (`<timestamp>_<id>.jsonl`), else the subagent's own id; `transcript_path` is that file. An id that is not 1 to 128 letters, digits, `_` or `-` is passed over. When no parent id can be resolved (an in-memory subagent, or a parent file that records no usable id), `session_id` and `transcript_path` are the subagent's own, together; an in-memory session has no `transcript_path`.
- A `SubagentStart` matcher is tried against `agent_type` and against its capitalised form, so `Explore` and a regex written for Claude Code such as `^(Explore|Plan)$` match OMP's `explore` and `plan`. Claude Code calls its general-purpose subagent `general-purpose`, while OMP's is `task`: a `general-purpose` matcher never fires, so write `task`. `agent_transcript_path` on `SubagentStart` (the subagent's own session file) is an OMP extension; Claude Code sends it on `SubagentStop`.

Subagents are recognized through `ctx.agent`, which OMP 18.5.1 and later provide; only `kind: "sub"` counts, and any other value is the main session. Older OMP still gets this behavior for subagents whose session file is nested under the parent's (`<parent>/<agentId>.jsonl`), but cannot see in-memory subagents or tell the agent type, so `agent_type` is absent.

### Approximated events

Two Claude events have no direct OMP counterpart but a near-equivalent trigger. They are off by default. Enable them in the live extension with `OMP_HOOKS_PLUS_APPROXIMATE=1`, or in a conversion with `--approximate`:

| Claude event | OMP trigger | Difference |
| --- | --- | --- |
| `Notification` `permission_prompt` | `tool_approval_requested` | Message is `Claude needs your permission to use <Tool>`. |
| `Notification` `idle_prompt` | a top-level `agent_end` that will not continue | Fires immediately, not after Claude Code's 60-second idle delay. |
| `SubagentStart` | first `before_agent_start` of a subagent session | Fires once per subagent, with the top-level `session_id` and `transcript_path` plus `agent_id`, `agent_type` and `agent_transcript_path` (see [Subagents](#subagents)). A matcher is matched against `agent_type` and its capitalised form; OMP's `task` agent is Claude Code's `general-purpose`. Without `ctx.agent` (OMP before 18.5.1) only groups without a matcher (or `*`) run, and in-memory subagents are not detected. |

Other notification types never fire. `/claude-compat doctor` lists the enabled approximations.

## Current limits

The compatibility layer intentionally does not load:

- organization-managed Claude policy hooks
- non-command handlers such as `http`, `prompt`, `agent`, and `mcp_tool` (including from a plugin manifest)
- hook event kinds beyond the nine listed [above](#supported-command-hooks) and the opt-in [approximated events](#approximated-events) (including from a plugin manifest — Claude Code defines 30+ event kinds in total). The doctor lists each such event with the settings file or plugin that declares it.

Plugin manifests and hook configuration files are re-read whenever hook settings are loaded, just like user settings. OMP caches installed-plugin roots separately; refresh plugin discovery after manually editing a plugin registry.

Claude Code's user-plugin registry is opt-in, following OMP's `claude`/`claude-plugins` user-source settings. OMP-managed plugins do not require that opt-in. This requires OMP 18.1.16 or later, whose discovery API exposes each plugin's registry origin.

The doctor reports these limits rather than implying full parity. OMP's native Claude provider continues to own skill discovery; this package does not copy or reimplement it.

Both the live bridge and generated hooks depend on OMP emitting the mapped events. In hosts where RPC/Ctrl+Enter bypass `input`, `UserPromptSubmit` cannot intercept those submissions. If queue-started runs bypass `before_agent_start`, prompt-context preparation is not delivered there. These are host dispatch limitations, not repaired by replaying input, hooking every provider request, or treating synthetic continuations as new user submissions.

Hook-produced text is content, not a slash-command invocation. The bridge creates no skill aliases and does not reinterpret `sendUserMessage` as command execution. Session shutdown still runs `SessionEnd` commands, but does not deliver reminders or start new turns after disposal.

## One-shot conversion

To generate standalone OMP hooks instead of using live discovery, run the converter from a checkout of this repository:

```sh
bun install --frozen-lockfile
bun run convert /path/to/claude-plugin --dry-run
bun run convert /path/to/claude-plugin --out /path/to/new-output
```

Load `/path/to/new-output/index.ts` as an OMP extension. The generated adapter and resources are self-contained: the converter and this bridge do not need to remain installed. OMP/Bun and the scripts' external executables and dependencies are still required.

For a settings file, resource inventory and copying cover only explicitly selected paths, not the surrounding project. An empty omission list therefore does not mean every project dependency was copied:

```sh
bun run convert /project/.claude/settings.json \
  --source-root /project --include scripts \
  --out /path/to/new-output --json
```

`--include` may be repeated. For plugin directories, supplying it restricts resource copying to the selected paths; omitting it copies all eligible resources. Declaration inventory always covers the whole plugin, including scoped hooks outside selected resources. Reports distinguish excluded resources from resources omitted by selection; neither category proves a file is unnecessary. A hook command that literally names a resource through `CLAUDE_PLUGIN_ROOT` (for example `"${CLAUDE_PLUGIN_ROOT}/hooks/run.sh"`) that is not copied fails the conversion, even with `--skip-unsupported`; paths a script builds at runtime are not checked. Included resources are available through `CLAUDE_PLUGIN_ROOT`; ordinary relative commands retain the active project's working directory. Output must be a new directory outside the source root, and its parent must already exist. Disable overlapping original hooks before enabling generated hooks.

Generated hooks default to `--activation enabled`: they run wherever OMP enables the extension, including untrusted projects. This preserves global policy hooks. For hooks that must not run in untrusted projects, select `--activation project-trusted`; the generated adapter checks OMP's current project trust before each settings lookup, including cached settings. Both modes preserve `disableAllHooks`. This is an execution gate, not a sandbox, and it does not change the automatic extension's existing user/project discovery and trust rules.

```sh
bun run convert /path/to/claude-plugin \
  --include hooks --include skills --activation project-trusted \
  --out /path/to/new-output
```

Exit codes: **0** supported, **1** invalid input/operational failure, **2** unsupported declarations or resources. By default, unsupported input produces a report only, never a partial runnable extension. `--skip-unsupported` converts the supported hooks anyway and lists every skipped declaration in the report; a command that embeds the original source root still fails with exit 2. `--approximate` converts the [approximated events](#approximated-events). `--dry-run` writes nothing; `--json` prints the inventory report.

```sh
# A Claude settings.json with SubagentStart and Notification hooks, converted whole:
bun run convert ~/.claude/settings.json --approximate --skip-unsupported --out /path/to/new-output
```

The converter inventories unsupported events, non-command handlers, unknown hook fields, and scoped frontmatter hooks rather than dropping them. String `statusMessage` metadata is accepted, with an explicit report that its UI presentation is not reproduced; malformed metadata remains invalid. Selected resource symlinks remain unsupported, and explicit includes cannot traverse symlinks. Unselected symlinks are reported as omitted without being followed; declaration containment checks still apply independently.

Reports identify the conversion level as `command-hook-adaptation` and record the effective activation policy. Detected Pi and OMP bindings are listed by declaration location as `not-reused`. Successful conversion is not a native port: native commands, recovery tools, provider integration, system-prompt ownership, and native session state are not reproduced. Keep manual native adapters where those capabilities are required.

Plugin resources exclude declaration files, package manifests/lockfiles, caches, common credentials, and environment files. Review reported exclusions, omissions, and script dependencies before use. Filename exclusions are not a secret scanner, and conversion does not prove arbitrary script dependency closure.

See the [conversion contract](docs/specs/standalone-hook-converter.md) and [implementation notes](docs/plans/standalone-hook-converter.md).

## Development

```sh
bun install
bun test
bun run typecheck
bun run build
```

OMP loads the TypeScript source entry directly from `src/omp-hooks.ts`.

## Upstream

This repository is based on [`ZeR020/omp-hooks`](https://github.com/ZeR020/omp-hooks) and retains its MIT license. The `upstream` Git remote tracks that project.
