singularity-forge/docs/dev/context-and-hooks/02-hook-reference.md

466 lines
15 KiB
Markdown
Raw Normal View History

# Hook Reference
Complete behavioral specification of every hook in pi's extension system. Covers timing, chaining semantics, return shapes, and edge cases not in the extending-pi docs.
---
## Hook Categories
1. **Input hooks** — intercept user input before the agent
2. **Agent lifecycle hooks** — control the agent loop boundary
3. **Per-turn hooks** — fire on every LLM call within an agent run
4. **Tool hooks** — intercept individual tool executions
5. **Session hooks** — respond to session lifecycle changes
6. **Model hooks** — respond to model changes
7. **Resource hooks** — provide dynamic resources at startup
---
## 1. Input Hooks
### `input`
**When:** User submits text (Enter in editor, RPC message, or `pi.sendUserMessage` from an extension with `source: "extension"`).
**Before:** Skill expansion, template expansion, command check (extension commands are checked before `input` fires, but built-in commands are checked after).
**Chaining:** Sequential through all extensions. Each handler sees the text output of the previous handler's `transform`. First `handled` stops the chain and the pipeline.
```typescript
pi.on("input", async (event, ctx) => {
// event.text: string — current text (possibly transformed by earlier handler)
// event.images: ImageContent[] | undefined
// event.source: "interactive" | "rpc" | "extension"
// Option 1: Pass through
return { action: "continue" };
// or return nothing (undefined) — same as continue
// Option 2: Transform
return { action: "transform", text: "rewritten", images: newImages };
// Option 3: Swallow (no LLM call, no further handlers)
return { action: "handled" };
});
```
**Edge cases:**
- Extension commands (`/mycommand`) are checked **before** `input` fires. If it matches, `input` never fires.
- Built-in commands (`/new`, `/model`, etc.) are checked **after** `input` transforms. So `input` can transform text into a built-in command, or transform a built-in command into something else.
- Images can be replaced via `transform`. Omitting `images` in the transform result preserves the original images.
---
## 2. Agent Lifecycle Hooks
### `before_agent_start`
**When:** After input processing, skill/template expansion, and the user message is constructed — but before `agent.prompt()` is called.
**Fires:** Once per user prompt. Does NOT fire on subsequent turns within the same agent run.
**Chaining:**
- **System prompt:** Chains. Extension A modifies `event.systemPrompt`, Extension B sees that modified version. If no extension returns a `systemPrompt`, the base prompt is used (resetting any previous turn's modifications).
- **Messages:** Accumulate. All `message` results are collected into an array. Each becomes a separate `CustomMessage` with `role: "custom"` injected after the user message.
```typescript
pi.on("before_agent_start", async (event, ctx) => {
// event.prompt: string — expanded user prompt text
// event.images: ImageContent[] | undefined
// event.systemPrompt: string — current system prompt (may be chained from earlier extension)
return {
// Optional: inject a custom message into the session
message: {
customType: "my-extension", // identifies the message type
content: "Text the LLM sees", // string or (TextContent | ImageContent)[]
display: true, // controls UI rendering, NOT LLM visibility
details: { any: "data" }, // for custom rendering and state reconstruction
},
// Optional: modify the system prompt for this agent run
systemPrompt: event.systemPrompt + "\nNew instructions",
};
});
```
**Critical detail:** The `display` field controls whether the message shows in the TUI chat log. The LLM **always** sees the message content regardless of `display`. All custom messages become `user` role messages in `convertToLlm`.
**Error handling:** If a handler throws, the error is captured and reported via `emitError`. Other handlers still run. The pipeline is not stopped.
### `agent_start`
**When:** The agent loop begins (after `before_agent_start`, after `agent.prompt()` is called).
**Fires:** Once per agent run. Informational only — no return value.
```typescript
pi.on("agent_start", async (event, ctx) => {
// event: { type: "agent_start" }
// Useful for: starting timers, resetting per-run state
});
```
### `agent_end`
**When:** The agent loop finishes (all turns complete, no more tool calls, no queued messages).
**Fires:** Once per agent run.
```typescript
pi.on("agent_end", async (event, ctx) => {
// event.messages: AgentMessage[] — all messages produced during this run
// Useful for: final summaries, state persistence, triggering follow-up actions
});
```
**Subtlety:** `event.messages` contains only the NEW messages from this agent run, not the full conversation history. Use `ctx.sessionManager.getBranch()` for the full history.
---
## 3. Per-Turn Hooks
### `turn_start`
**When:** Each turn within the agent loop begins (before the LLM call).
```typescript
pi.on("turn_start", async (event, ctx) => {
// event.turnIndex: number — 0-based index of this turn within the agent run
// event.timestamp: number — when the turn started
});
```
### `context`
**When:** Before each LLM call, after the turn starts. This is the last chance to modify what the LLM sees.
**Fires:** Every turn. If the LLM calls 3 tools and loops back, `context` fires 4 times (once for initial call + once per loop-back).
**Chaining:** Sequential. Each handler receives the output of the previous. First handler gets a `structuredClone` deep copy of the agent's message array.
```typescript
pi.on("context", async (event, ctx) => {
// event.messages: AgentMessage[] — deep copy, safe to mutate
// Filter out messages
const filtered = event.messages.filter(m => !isIrrelevant(m));
return { messages: filtered };
// Or inject messages
return { messages: [...event.messages, syntheticMessage] };
// Or return nothing to pass through unchanged
});
```
**What `event.messages` contains:**
- All roles: `user`, `assistant`, `toolResult`, `custom`, `bashExecution`, `compactionSummary`, `branchSummary`
- The user message from the current prompt
- Custom messages injected by `before_agent_start`
- Tool results from earlier turns in this agent run
- Steering/follow-up messages that became turn inputs
- Historical messages from the session (including compaction summaries)
**What it does NOT contain:**
- The system prompt (use `before_agent_start` for that)
- Tool definitions (use `pi.setActiveTools()` for that)
### `turn_end`
**When:** After the LLM responds and all tool calls for this turn complete.
```typescript
pi.on("turn_end", async (event, ctx) => {
// event.turnIndex: number
// event.message: AgentMessage — the assistant's response message
// event.toolResults: ToolResultMessage[] — results from tools called this turn
});
```
### `message_start` / `message_update` / `message_end`
**When:** Message lifecycle events. `update` only fires for assistant messages during streaming (token-by-token).
```typescript
pi.on("message_start", async (event, ctx) => {
// event.message: AgentMessage — user, assistant, toolResult, or custom
});
pi.on("message_update", async (event, ctx) => {
// event.message: AgentMessage — partial assistant message (streaming)
// event.assistantMessageEvent: AssistantMessageEvent — the specific token event
});
pi.on("message_end", async (event, ctx) => {
// event.message: AgentMessage — final message
// Messages are persisted to the session file at this point
});
```
---
## 4. Tool Hooks
### `tool_call`
**When:** After the LLM requests a tool call, before it executes.
**Chaining:** Sequential. If any handler returns `{ block: true }`, execution stops immediately. The block reason becomes an Error that is caught and returned as the tool result with `isError: true`.
```typescript
pi.on("tool_call", async (event, ctx) => {
// event.toolCallId: string
// event.toolName: string
// event.input: typed based on tool (use isToolCallEventType for narrowing)
// Block execution
return { block: true, reason: "Not allowed in read-only mode" };
// Allow execution (return nothing or undefined)
});
```
**Type narrowing:**
```typescript
import { isToolCallEventType } from "@mariozechner/pi-coding-agent";
pi.on("tool_call", async (event, ctx) => {
if (isToolCallEventType("bash", event)) {
event.input.command; // string — typed!
}
if (isToolCallEventType("write", event)) {
event.input.path; // string
event.input.content; // string
}
// Custom tools need explicit type params:
if (isToolCallEventType<"my_tool", { action: string }>("my_tool", event)) {
event.input.action; // string
}
});
```
### `tool_execution_start` / `tool_execution_update` / `tool_execution_end`
Informational events during tool execution. No return values.
```typescript
pi.on("tool_execution_start", async (event) => {
// event.toolCallId, event.toolName, event.args
});
pi.on("tool_execution_update", async (event) => {
// event.partialResult — streaming progress from onUpdate callback
});
pi.on("tool_execution_end", async (event) => {
// event.result, event.isError
});
```
### `tool_result`
**When:** After a tool finishes executing, before the result is returned to the agent loop.
**Chaining:** Sequential. Each handler can modify the result. Modifications accumulate across handlers. All handlers see the evolving `currentEvent` with content/details/isError updated by previous handlers.
```typescript
pi.on("tool_result", async (event, ctx) => {
// event.toolCallId: string
// event.toolName: string
// event.input: Record<string, unknown>
// event.content: (TextContent | ImageContent)[]
// event.details: unknown
// event.isError: boolean
// Modify the result
return {
content: [...event.content, { type: "text", text: "\n\nAudit: logged" }],
isError: false, // can flip error state
};
// Return nothing to pass through unchanged
});
```
**Also fires for errors:** If tool execution throws, `tool_result` still fires with `isError: true` and the error message as content. Extensions can modify even error results.
---
## 5. Session Hooks
### `session_start`
**When:** Initial session load (startup) and after session switch/fork. Also fires after `/reload`.
**Use for:** State restoration from session entries, initial setup.
```typescript
pi.on("session_start", async (_event, ctx) => {
// Restore state from session
for (const entry of ctx.sessionManager.getBranch()) {
if (entry.type === "custom" && entry.customType === "my-state") {
myState = entry.data;
}
}
});
```
### `session_before_switch` / `session_switch`
**When:** Before/after `/new` or `/resume`.
```typescript
pi.on("session_before_switch", async (event) => {
// event.reason: "new" | "resume"
// event.targetSessionFile?: string (only for resume)
return { cancel: true }; // prevent the switch
});
```
### `session_before_fork` / `session_fork`
**When:** Before/after `/fork`.
```typescript
pi.on("session_before_fork", async (event) => {
// event.entryId: string — the entry being forked from
return { cancel: true };
// or
return { skipConversationRestore: true }; // fork without restoring messages
});
```
### `session_before_compact` / `session_compact`
**When:** Before/after compaction (manual or auto).
```typescript
pi.on("session_before_compact", async (event) => {
// event.preparation: CompactionPreparation
// event.branchEntries: SessionEntry[]
// event.customInstructions?: string
// event.signal: AbortSignal
return { cancel: true };
// or provide custom compaction:
return {
compaction: {
summary: "My custom summary",
firstKeptEntryId: event.preparation.firstKeptEntryId,
tokensBefore: event.preparation.tokensBefore,
}
};
});
```
### `session_before_tree` / `session_tree`
**When:** Before/after `/tree` navigation.
```typescript
pi.on("session_before_tree", async (event) => {
// event.preparation: TreePreparation
// event.signal: AbortSignal
return { cancel: true };
// or provide custom summary:
return {
summary: { summary: "Custom branch summary" },
label: "my-label",
};
});
```
### `session_shutdown`
**When:** Process exit (Ctrl+C, Ctrl+D, SIGTERM, `ctx.shutdown()`).
```typescript
pi.on("session_shutdown", async (_event, ctx) => {
// Last chance to persist state
// Keep it fast — process is exiting
});
```
---
## 6. Model Hooks
### `model_select`
**When:** Model changes via `/model`, Ctrl+P cycling, or session restore.
```typescript
pi.on("model_select", async (event, ctx) => {
// event.model: Model — the new model
// event.previousModel: Model | undefined
// event.source: "set" | "cycle" | "restore"
});
```
---
## 7. Resource Hooks
### `resources_discover`
**When:** At startup and after `/reload`. Lets extensions provide additional skill, prompt template, and theme paths.
**Not documented in extending-pi docs.** This is how extensions ship their own resources.
```typescript
pi.on("resources_discover", async (event, ctx) => {
// event.cwd: string
// event.reason: "startup" | "reload"
return {
skillPaths: [join(__dirname, "skills", "SKILL.md")],
promptPaths: [join(__dirname, "prompts", "my-template.md")],
themePaths: [join(__dirname, "themes", "dark.json")],
};
});
```
**Behavior:** Returned paths are loaded by the resource loader and integrated into the system prompt (skills) and available commands (prompts/themes). The system prompt is rebuilt after resources are extended.
---
## 8. User Bash Hooks
### `user_bash`
**When:** User executes a command via `!` or `!!` prefix in the editor.
```typescript
pi.on("user_bash", async (event, ctx) => {
// event.command: string
// event.excludeFromContext: boolean (true if !! prefix)
// event.cwd: string
// Provide custom execution (e.g., SSH)
return {
operations: { execute: (cmd) => sshExec(remote, cmd) },
};
// Or provide a full replacement result
return {
result: { output: "custom output", exitCode: 0, cancelled: false, truncated: false },
};
});
```
---
## Execution Order Across Extensions
All hooks iterate through extensions in **load order** (project-local first, then global, then explicitly configured via `-e`). Within each extension, handlers for the same event run in registration order.
For hooks that chain (e.g., `context`, `before_agent_start.systemPrompt`, `input`, `tool_result`):
- Extension A's handler runs first, Extension B sees A's output
- Load order determines priority
For hooks that short-circuit (e.g., `tool_call` with `block`, `input` with `handled`, session `cancel`):
- First extension to return the short-circuit value wins
- Remaining handlers are skipped