1dba122c创建于 1 天前历史提交

name: authoring-hooks description: Use when creating a new omp hook. Covers HookAPI, event catalog, blocking/overriding tool calls, and context modification.

Authoring Hooks

Hooks are event-driven interceptors that run alongside the agent loop. They are best used for cross-cutting concerns: safety policy, secret redaction, context pruning, audit logging. A hook module registers handlers via pi.on(event, handler) and can block tool execution, override tool output, or rewrite the message context before each LLM call.

Relationship to extensions: The hook subsystem (HookAPI) is the legacy API. The extension runner now handles everything hooks can do plus more. ExtensionAPI supports the hook event model plus extension-only events. Use ExtensionAPI for new work; use HookAPI only if you are maintaining an existing hook module.

Factory signature

import type { HookAPI } from "@oh-my-pi/pi-coding-agent/extensibility/hooks";

export default function myHook(omp: HookAPI): void {
  omp.on("tool_call", async (event, ctx) => {
    // intercept every tool call
  });
}

The default export must be a plain function (not async, not a class). It receives a HookAPI instance and must register all handlers synchronously during execution.

Alternatively, using ExtensionAPI (preferred):

import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";

export default function myExtension(pi: ExtensionAPI): void {
  pi.on("tool_call", async (event, ctx) => { /* ... */ });
}

Event catalog

Tool lifecycle

Event Fires Can return
tool_call Before every tool execution { block?: boolean; reason?: string }
tool_result After every tool execution { content?; details?; isError?: boolean }

Session lifecycle

Event Fires Can return
session_start On initial session load
session_before_switch Before session switch { cancel?: boolean }
session_switch After session switch
session_before_branch Before session branch { cancel?: boolean; skipConversationRestore?: boolean }
session_branch After session branch
session_before_compact Before compaction { cancel?: boolean; compaction?: CompactionResult }
session.compacting During compaction (inject context) { context?: string[]; prompt?: string; preserveData?: Record<string, unknown> }
session_compact After compaction
session_before_tree Before tree navigation { cancel?: boolean; summary?: { summary: string; details?: unknown } }
session_tree After tree navigation
session_shutdown On session shutdown

Agent/turn lifecycle

Event Fires Can return
before_agent_start Before agent starts a turn { message?: { customType; content; display; details; attribution? } }
agent_start Agent streaming starts
agent_end Agent streaming ends
turn_start Start of a user→agent turn
turn_end End of a user→agent turn
context Before each LLM API call { messages?: Message[] }
auto_compaction_start Auto-compaction begins
auto_compaction_end Auto-compaction ends
auto_retry_start Auto-retry begins
auto_retry_end Auto-retry ends
ttsr_triggered TTSR (too-short response) triggered
todo_reminder Todo reminder fires

Extension-only events such as tool_execution_start, tool_execution_update, tool_execution_end, input, user_bash, and user_python require ExtensionAPI.

Pre-tool blocking contract

Return { block: true, reason: "..." } from a tool_call handler to prevent execution:

omp.on("tool_call", async (event, ctx) => {
  if (event.toolName === "bash") {
    const cmd = String(event.input.command ?? "");
    if (/\brm\s+-rf\s+\//.test(cmd)) {
      return { block: true, reason: "Refusing to delete root filesystem" };
    }
  }
});

Contract:

  • If any handler returns { block: true }, execution stops immediately.
  • reason is returned to the LLM as the tool error text.
  • If a handler throws, the tool is also blocked (fail-closed).
  • Last non-blocking return wins for non-blocking results; first block: true short-circuits.

Post-tool override contract

Return { content, details, isError } from a tool_result handler to patch what the LLM sees:

omp.on("tool_result", async (event, ctx) => {
  if (event.toolName === "read" && !event.isError) {
    const redacted = event.content.map(chunk => {
      if (chunk.type !== "text") return chunk;
      return {
        ...chunk,
        text: chunk.text.replace(/(?:sk|pk)-[a-zA-Z0-9]{20,}/g, "[REDACTED_API_KEY]"),
      };
    });
    return { content: redacted };
  }
});

Contract:

  • Handlers run in registration order. For HookAPI, each handler receives the original tool result event, and the last returned override wins.
  • content replaces the full content array for the LLM.
  • details replaces the structured details object.
  • isError exists on the shared result type, but HookToolWrapper does not propagate it into a successful tool result; on a tool failure, the original error is rethrown after handlers complete.
  • On a tool failure, tool_result is still emitted with isError: true.

Context modification contract

Return { messages: [...] } from a context handler to rewrite the message list before each LLM API call:

omp.on("context", async (event, ctx) => {
  // Remove debug-only custom messages from LLM context
  const filtered = event.messages.filter(
    msg => !(msg.role === "custom" && msg.customType === "debug-only")
  );
  return { messages: filtered };
});

Contract:

  • event.messages is the current accumulated list.
  • Handlers run in order; each receives the output of the previous handler.
  • Return undefined (or nothing) to pass messages through unmodified.

Three complete examples

1. rm-rf blocker

import type { HookAPI } from "@oh-my-pi/pi-coding-agent/extensibility/hooks";

export default function rmRfBlocker(omp: HookAPI): void {
  omp.on("tool_call", async (event, ctx) => {
    if (event.toolName !== "bash") return;

    const cmd = String(event.input.command ?? "");
    if (!/\brm\s+-rf\s+\//.test(cmd)) return;

    // Allow if user explicitly confirms (interactive mode only)
    if (ctx.hasUI) {
      const allow = await ctx.ui.confirm(
        "Dangerous command",
        `This command deletes from root:\n${cmd}\n\nProceed?`
      );
      if (allow) return;
    }

    return { block: true, reason: "rm -rf / blocked by safety policy" };
  });
}

2. API-key redactor

import type { HookAPI } from "@oh-my-pi/pi-coding-agent/extensibility/hooks";

// Matches common API key patterns: sk-..., pk-..., AKIA..., ghp_..., etc.
const SECRET_PATTERNS = [
  /\b(sk|pk)-[a-zA-Z0-9]{20,}\b/g,
  /\bAKIA[A-Z0-9]{16}\b/g,
  /\bghp_[a-zA-Z0-9]{36}\b/g,
  /\b[a-zA-Z0-9_-]{20,}\s*=\s*["']?[a-zA-Z0-9._/+=-]{20,}["']?/g,
];

export default function apiKeyRedactor(omp: HookAPI): void {
  omp.on("tool_result", async (event) => {
    if (event.isError) return;

    let changed = false;
    const redacted = event.content.map(chunk => {
      if (chunk.type !== "text") return chunk;
      let text = chunk.text;
      for (const pattern of SECRET_PATTERNS) {
        const next = text.replace(pattern, "[REDACTED]");
        if (next !== text) { changed = true; text = next; }
      }
      return { ...chunk, text };
    });

    if (changed) return { content: redacted };
  });
}

3. Context filter

import type { HookAPI } from "@oh-my-pi/pi-coding-agent/extensibility/hooks";

export default function contextFilter(omp: HookAPI): void {
  omp.on("context", async (event) => {
    const MAX_TOOL_OUTPUT_CHARS = 8_000;

    const trimmed = event.messages.map(msg => {
      // Truncate very large tool results to keep context manageable
      if (msg.role !== "tool") return msg;
      const content = msg.content.map(chunk => {
        if (chunk.type !== "text" || chunk.text.length <= MAX_TOOL_OUTPUT_CHARS) return chunk;
        return {
          ...chunk,
          text: chunk.text.slice(0, MAX_TOOL_OUTPUT_CHARS) + "\n[... truncated by context-filter hook]",
        };
      });
      return { ...msg, content };
    });

    return { messages: trimmed };
  });
}

UI methods in hook context

ctx.ui is a HookUIContext. Available methods:

Method Description
notify(message, type?) Show an in-app notification
setStatus(key, text) Set footer status text (keyed, sorted by key)
select(title, options) Show a selection dialog
confirm(title, message) Show a yes/no dialog
input(title, placeholder?) Show a text input dialog
editor(title, prefill?, { signal }?, { promptStyle }?) Show a multi-line editor
setEditorText(text) Set the input editor content
getEditorText() Get current input editor content
custom(factory) Render a custom TUI component
theme Current theme object

Pass { promptStyle: true } as the fourth argument when Enter should submit and Shift+Enter should insert a newline. The default hook editor behavior keeps Enter as newline and Ctrl+Enter as submit.

ctx.hasUI is false in headless/print/subagent mode — always guard interactive calls.

Further reading

  • docs/hooks.md — hook subsystem internals, ordering rules, error propagation
  • docs/extensions.mdExtensionAPI (superset of HookAPI)
  • docs/skills/examples/safety-hook/ — complete working example