Files
2026-07-13 12:22:59 +08:00

12 KiB
Raw Permalink Blame History

Hooks

This document describes the current hook subsystem code in src/extensibility/hooks/*.

Current status in runtime

The default CLI runtime initializes the extension runner path. In current startup flow:

  • --hook is treated as an alias for --extension (CLI paths are merged into additionalExtensionPaths)
  • JS/TS hook factories discovered through hookCapability (for example .omp/hooks/pre/*.ts) are loaded as extension modules so their pi.on(...) handlers bind to the runtime event bus
  • tools are wrapped by ExtensionToolWrapper, not HookToolWrapper
  • context transforms and lifecycle emissions go through ExtensionRunner

So this file documents the legacy hook subsystem implementation itself (types/loader/runner/wrapper), plus the factory shape still accepted when a discovered hook path is loaded by the extension runner.

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:

import type { HookAPI } from "@oh-my-pi/pi-coding-agent/extensibility/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(...)
  • author schemas/helpers with injected pi.zod, pi.typebox, and package exports via pi.pi

Discovery and loading

Default sessions load JS/TS hook factories discovered by hookCapability through the extension runner. discoverExtensionPaths(configuredPaths, cwd) does:

  1. Load native extension modules from the capability registry
  2. Load importable .ts/.js hook factories from the hook capability registry
  3. Append plugin extension entry points
  4. Append explicitly configured paths

The legacy discoverAndLoadHooks(configuredPaths, cwd) helper still exists and 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

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; attribution } }
  • 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 subsystems core pre/post interception model.

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 handlers 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 includes hasUI, cwd, sessionManager, modelRegistry, current model, isIdle(), abort(), and hasQueuedMessages().

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 (ANSI/VT escape sequences stripped; control characters mapped to spaces; repeated spaces collapsed; trimmed)
  • 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

import type { HookAPI } from "@oh-my-pi/pi-coding-agent/extensibility/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

import type { HookAPI } from "@oh-my-pi/pi-coding-agent/extensibility/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

import type { HookAPI } from "@oh-my-pi/pi-coding-agent/extensibility/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

import type { HookAPI } from "@oh-my-pi/pi-coding-agent/extensibility/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 and the package subpath @oh-my-pi/pi-coding-agent/extensibility/hooks export:

  • loading APIs (discoverAndLoadHooks, loadHooks)
  • runner and wrapper (HookRunner, HookToolWrapper)
  • all hook types
  • execCommand re-export

The package root (@oh-my-pi/pi-coding-agent) does not re-export HookAPI; import legacy hook types from the hooks subpath.