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:
--hookis treated as an alias for--extension(CLI paths are merged intoadditionalExtensionPaths)- tools are wrapped by
ExtensionToolWrapper, notHookToolWrapper - 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 contractssrc/extensibility/hooks/loader.ts— module loading and hook discovery bridgesrc/extensibility/hooks/runner.ts— event dispatch, command lookup, error signalingsrc/extensibility/hooks/tool-wrapper.ts— pre/post tool interception wrappersrc/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/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:
- Load discovered hooks from capability registry (
loadCapability("hooks")) - Append explicitly configured paths (deduped by absolute path)
- 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_startsession_before_switch→ can return{ cancel?: boolean }session_switchsession_before_branch→ can return{ cancel?: boolean; skipConversationRestore?: boolean }session_branchsession_before_compact→ can return{ cancel?: boolean; compaction?: CompactionResult }session.compacting→ can return{ context?: string[]; prompt?: string; preserveData?: Record<string, unknown> }session_compactsession_before_tree→ can return{ cancel?: boolean; summary?: { summary: string; details?: unknown } }session_treesession_shutdown
Agent/context events
context→ can return{ messages?: Message[] }before_agent_start→ can return{ message?: { customType; content; display; details } }agent_startagent_endturn_startturn_endauto_compaction_startauto_compaction_endauto_retry_startauto_retry_endttsr_triggeredtodo_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.
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 errorExecution 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
reasonbecomes 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,inputcontentdetailsisError: false
If handler returns overrides:
contentcan replace result contentdetailscan 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(messagesreplacement chain) - tool output content/details on successful tool calls (
tool_resultpath) - pre-agent injected message via
before_agent_start - cancellation/custom compaction/tree behavior via
session_before_*andsession.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
isErroris typed but not applied byHookToolWrapper)
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:
- hooks array order
- handler registration order per hook/event
Conflict behavior by event type:
tool_call: last returned result wins unless a handler blocks; first block short-circuitstool_result: last returned override wins (no short-circuit)context: chained; each handler receives prior handler’s message outputbefore_agent_start: first returned message is kept; later messages ignoredsession_before_*: latest returned result is tracked;cancel: trueshort-circuits immediatelysession.compacting: latest returned result wins
Command/renderer conflicts:
getCommand(name)returns first match across hooks (first loaded wins)getMessageRenderer(customType)returns first matchgetRegisteredCommands()returns all commands (no dedupe)
UI interactions (HookContext.ui)
HookUIContext includes:
select,confirm,input,editornotifysetStatuscustomsetEditorText,getEditorTextthemegetter
ctx.hasUI indicates whether interactive UI is available.
When running with no UI, the default no-op context behavior is:
select/input/editorreturnundefinedconfirmreturnsfalsenotify,setStatus,setEditorTextare no-opsgetEditorTextreturns""
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
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
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
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
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
execCommandre-export
And package root (src/index.ts) re-exports hook types as a legacy compatibility surface.