Skip to content

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/*, .pisces/.../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.