Files
oh-my-pi/docs/hooks.md
T
can1357 2e45297c43 docs(docs): moved documentation to root docs directory and updated all references
- Moved documentation files from packages/coding-agent/docs/ to root docs/ directory to flatten the documentation structure.
- Updated all internal documentation links to account for the new file locations, adjusting relative paths to maintain correct references across the monorepo.
- Updated README.md and issue template configuration to reference documentation at the new root docs/ location instead of packages/coding-agent/docs/.
2026-02-16 18:33:03 +01:00

337 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Hooks
This document describes the **current hook subsystem code** in `src/extensibility/hooks/*`.
## Current status in runtime
The hook package (`src/extensibility/hooks/`) is still exported and usable as an API surface, but the default CLI runtime now initializes the **extension runner** path. In current startup flow:
- `--hook` is treated as an alias for `--extension` (CLI paths are merged into `additionalExtensionPaths`)
- tools are wrapped by `ExtensionToolWrapper`, not `HookToolWrapper`
- context transforms and lifecycle emissions go through `ExtensionRunner`
So this file documents the hook subsystem implementation itself (types/loader/runner/wrapper), including legacy behavior and constraints.
## Key files
- `src/extensibility/hooks/types.ts` — hook context, event types, and result contracts
- `src/extensibility/hooks/loader.ts` — module loading and hook discovery bridge
- `src/extensibility/hooks/runner.ts` — event dispatch, command lookup, error signaling
- `src/extensibility/hooks/tool-wrapper.ts` — pre/post tool interception wrapper
- `src/extensibility/hooks/index.ts` — exports/re-exports
## What a hook module is
A hook module must default-export a factory:
```ts
import type { HookAPI } from "@oh-my-pi/pi-coding-agent/hooks";
export default function hook(pi: HookAPI): void {
pi.on("tool_call", async (event, ctx) => {
if (event.toolName === "bash" && String(event.input.command ?? "").includes("rm -rf")) {
return { block: true, reason: "blocked by policy" };
}
});
}
```
The factory can:
- register event handlers with `pi.on(...)`
- send persistent custom messages with `pi.sendMessage(...)`
- persist non-LLM state with `pi.appendEntry(...)`
- register slash commands via `pi.registerCommand(...)`
- register custom message renderers via `pi.registerMessageRenderer(...)`
- run shell commands via `pi.exec(...)`
## Discovery and loading
`discoverAndLoadHooks(configuredPaths, cwd)` does:
1. Load discovered hooks from capability registry (`loadCapability("hooks")`)
2. Append explicitly configured paths (deduped by absolute path)
3. Call `loadHooks(allPaths, cwd)`
`loadHooks` then imports each path and expects a `default` function.
### Path resolution
`loader.ts` resolves hook paths as:
- absolute path: used as-is
- `~` path: expanded
- relative path: resolved against `cwd`
### Important legacy mismatch
Discovery providers for `hookCapability` still model pre/post shell-style hook files (for example `.claude/hooks/pre/*`, `.omp/.../hooks/pre/*`).
The hook loader here uses dynamic module import and requires a default JS/TS hook factory. If a discovered hook path is not importable as a module, load fails and is reported in `LoadHooksResult.errors`.
## Event surfaces
Hook events are strongly typed in `types.ts`.
### Session events
- `session_start`
- `session_before_switch` → can return `{ cancel?: boolean }`
- `session_switch`
- `session_before_branch` → can return `{ cancel?: boolean; skipConversationRestore?: boolean }`
- `session_branch`
- `session_before_compact` → can return `{ cancel?: boolean; compaction?: CompactionResult }`
- `session.compacting` → can return `{ context?: string[]; prompt?: string; preserveData?: Record<string, unknown> }`
- `session_compact`
- `session_before_tree` → can return `{ cancel?: boolean; summary?: { summary: string; details?: unknown } }`
- `session_tree`
- `session_shutdown`
### Agent/context events
- `context` → can return `{ messages?: Message[] }`
- `before_agent_start` → can return `{ message?: { customType; content; display; details } }`
- `agent_start`
- `agent_end`
- `turn_start`
- `turn_end`
- `auto_compaction_start`
- `auto_compaction_end`
- `auto_retry_start`
- `auto_retry_end`
- `ttsr_triggered`
- `todo_reminder`
### Tool events (pre/post model)
- `tool_call` (pre-execution) → can return `{ block?: boolean; reason?: string }`
- `tool_result` (post-execution) → can return `{ content?; details?; isError? }`
This is the hook subsystem’s core pre/post interception model.
```text
Hook tool interception flow
tool_call handlers
│
├─ any { block: true }? ── yes ──> throw (tool blocked)
│
└─ no
│
▼
execute underlying tool
│
├─ success ──> tool_result handlers can override { content, details }
│
└─ error ──> emit tool_result(isError=true) then rethrow original error
```
## Execution model and mutation semantics
### 1) Pre-execution: `tool_call`
`HookToolWrapper.execute()` emits `tool_call` before tool execution.
- if any handler returns `{ block: true }`, execution stops
- if handler throws, wrapper fails closed and blocks execution
- returned `reason` becomes the thrown error text
### 2) Tool execution
Underlying tool executes normally if not blocked.
### 3) Post-execution: `tool_result`
After success, wrapper emits `tool_result` with:
- `toolName`, `toolCallId`, `input`
- `content`
- `details`
- `isError: false`
If handler returns overrides:
- `content` can replace result content
- `details` can replace result details
On tool failure, wrapper emits `tool_result` with `isError: true` and error text content, then rethrows original error.
### What hooks can mutate
- LLM context for a single call via `context` (`messages` replacement chain)
- tool output content/details on successful tool calls (`tool_result` path)
- pre-agent injected message via `before_agent_start`
- cancellation/custom compaction/tree behavior via `session_before_*` and `session.compacting`
### What hooks cannot mutate in this implementation
- raw tool input parameters in-place (only block/allow on `tool_call`)
- execution continuation after thrown tool errors (error path rethrows)
- final success/error status in wrapper behavior (returned `isError` is typed but not applied by `HookToolWrapper`)
## Ordering and conflict behavior
### Discovery-level ordering
Capability providers are priority-sorted (higher first). Dedupe is by capability key, first wins.
For `hooks`, capability key is `${type}:${tool}:${name}`. Shadowed duplicates from lower-priority providers are marked and excluded from effective discovered list.
### Load order
`discoverAndLoadHooks` builds a flat `allPaths` list, deduped by resolved absolute path, then `loadHooks` iterates in that order.
File order within each discovered directory depends on `readdir` output; the hook loader does not perform an additional sort.
### Runtime handler order
Inside `HookRunner`, order is deterministic by registration sequence:
1. hooks array order
2. handler registration order per hook/event
Conflict behavior by event type:
- `tool_call`: last returned result wins unless a handler blocks; first block short-circuits
- `tool_result`: last returned override wins (no short-circuit)
- `context`: chained; each handler receives prior handler’s message output
- `before_agent_start`: first returned message is kept; later messages ignored
- `session_before_*`: latest returned result is tracked; `cancel: true` short-circuits immediately
- `session.compacting`: latest returned result wins
Command/renderer conflicts:
- `getCommand(name)` returns first match across hooks (first loaded wins)
- `getMessageRenderer(customType)` returns first match
- `getRegisteredCommands()` returns all commands (no dedupe)
## UI interactions (`HookContext.ui`)
`HookUIContext` includes:
- `select`, `confirm`, `input`, `editor`
- `notify`
- `setStatus`
- `custom`
- `setEditorText`, `getEditorText`
- `theme` getter
`ctx.hasUI` indicates whether interactive UI is available.
When running with no UI, the default no-op context behavior is:
- `select/input/editor` return `undefined`
- `confirm` returns `false`
- `notify`, `setStatus`, `setEditorText` are no-ops
- `getEditorText` returns `""`
### Status line behavior
Hook status text set via `ctx.ui.setStatus(key, text)` is:
- stored per key
- sorted by key name
- sanitized (`\r`, `\n`, `\t` → spaces; repeated spaces collapsed)
- joined and width-truncated for display
## Error propagation and fallback
### Load-time
- invalid module or missing default export → captured in `LoadHooksResult.errors`
- loading continues for other hooks
### Event-time
`HookRunner.emit(...)` catches handler errors for most events and emits `HookError` to listeners (`hookPath`, `event`, `error`), then continues.
`emitToolCall(...)` is stricter: handler errors are not swallowed there; they propagate to caller. In `HookToolWrapper`, this blocks the tool call (fail-safe).
## Realistic API examples
### Block unsafe bash commands
```ts
import type { HookAPI } from "@oh-my-pi/pi-coding-agent/hooks";
export default function (pi: HookAPI): void {
pi.on("tool_call", async (event, ctx) => {
if (event.toolName !== "bash") return;
const cmd = String(event.input.command ?? "");
if (!cmd.includes("rm -rf")) return;
if (!ctx.hasUI) return { block: true, reason: "rm -rf blocked (no UI)" };
const ok = await ctx.ui.confirm("Dangerous command", `Allow: ${cmd}`);
if (!ok) return { block: true, reason: "user denied command" };
});
}
```
### Redact tool output on post-execution
```ts
import type { HookAPI } from "@oh-my-pi/pi-coding-agent/hooks";
export default function (pi: HookAPI): void {
pi.on("tool_result", async event => {
if (event.toolName !== "read" || event.isError) return;
const redacted = event.content.map(chunk => {
if (chunk.type !== "text") return chunk;
return { ...chunk, text: chunk.text.replaceAll(/API_KEY=\S+/g, "API_KEY=[REDACTED]") };
});
return { content: redacted };
});
}
```
### Modify model context per LLM call
```ts
import type { HookAPI } from "@oh-my-pi/pi-coding-agent/hooks";
export default function (pi: HookAPI): void {
pi.on("context", async event => {
const filtered = event.messages.filter(msg => !(msg.role === "custom" && msg.customType === "debug-only"));
return { messages: filtered };
});
}
```
### Register slash command with command-safe context methods
```ts
import type { HookAPI } from "@oh-my-pi/pi-coding-agent/hooks";
export default function (pi: HookAPI): void {
pi.registerCommand("handoff", {
description: "Create a new session with setup message",
handler: async (_args, ctx) => {
await ctx.waitForIdle();
await ctx.newSession({
parentSession: ctx.sessionManager.getSessionFile(),
setup: async sm => {
sm.appendMessage({
role: "user",
content: [{ type: "text", text: "Continue from prior session summary." }],
timestamp: Date.now(),
});
},
});
},
});
}
```
## Export surface
`src/extensibility/hooks/index.ts` exports:
- loading APIs (`discoverAndLoadHooks`, `loadHooks`)
- runner and wrapper (`HookRunner`, `HookToolWrapper`)
- all hook types
- `execCommand` re-export
And package root (`src/index.ts`) re-exports hook **types** as a legacy compatibility surface.