Events and the Extension Lifecycle
Pi dispatches named events to handlers in registration order; here is the run lifecycle, what each event may return, and a force-push guard built on tool_call.
The review guard can suggest running the checks. It cannot stop a git push --force that the model decides to run while fixing a conflict.
A tool_call handler can block a matching invocation of a tool before its body runs. That makes it a useful extension-level guard, not an unconditional prohibition: commands can reach the same effect through other tools or paths the handler does not inspect. Getting this right requires knowing the lifecycle, because handlers run in registration order and a return value only matters for events declared to accept one.
How events dispatch
extensions.md states three rules:
- Handlers run in extension load and registration order.
pi.on()returns a function that unsubscribes that registration; changes do not affect a dispatch already in progress.- Some events notify; others transform data, replace results, or cancel an operation.
And then the one that prevents most mistakes:
Use each event’s declared result type rather than assuming every return value has an effect.
Handlers for a given event are awaited, so a slow handler can delay processing. Error handling depends on the event: notably, a failing tool_call handler blocks the affected tool invocation rather than allowing it to proceed.
The shape of a run
extensions.md describes it as: a run proceeds from input and before_agent_start, through model, message, and tool events, to agent_end. Automatic retries, recovery, compaction, or queued work can continue afterward.
That last sentence is the one that matters. agent_end is not the end.
sequenceDiagram
participant U as You
participant P as Pi
participant H as Handlers
U->>P: submit a prompt
P->>H: input
P->>H: before_agent_start
P->>H: agent_start
loop once per turn
P->>H: turn_start
P->>H: message_start, message_update, message_end
P->>H: tool_call, before the tool runs
H-->>P: block, or a patched input
P->>H: tool_result
P->>H: turn_end
end
P->>H: agent_end
P->>H: agent_before_settle
alt continuation requested
H-->>P: entries and continue true
P->>P: run additional model work
P->>H: agent_before_settle (when ready again)
else no further work
P->>H: agent_settled
end
Note over H: settlement occurs only after continuation is exhausted
The drawing is a simplified event-order guide, not a trace of every execution path. Stream and tool events can interleave, tool calls may run concurrently, and settlement can follow more than one low-level run. provider_stream_event, session_before_compact, user_bash and cache_warming_decision are omitted rather than assigned a misleading fixed position; the table below covers the events relevant to this chapter.
Two boundaries close the sequence:
agent_before_settleis the final actionable boundary: it can append entries and request one continuation.agent_settledis final and notification-only; use it when an integration needs to know Pi will not continue automatically.
Alongside turn_end, agent_before_settle is an actionable boundary. Its handler can chain proposed custom, custom_message, context_edit or compaction entries and return continue: true for one next model request. Guard the condition, because an unconditional continuation can loop.
Wrong: "agent_end means the agent is finished."
Correct: "agent_end closes one low-level run. agent_before_settle is the last boundary that can still add work, and agent_settled is the notification that nothing more will happen on its own."
The events that change behaviour
Rather than list every event name, these are the ones an extension author reaches for:
| Event | Shape | What it can do |
|---|---|---|
project_trust |
cwd, mode, hasUI, ui, trusted |
Return { trusted: "yes" | "no" | "undecided" }; the first to decide owns it |
session_start |
reason (startup, reload, new, resume, fork) |
Acquire session-scoped resources |
session_shutdown |
reason, targetSessionFile? |
Release them, idempotently |
input |
text, images?, source, streamingBehavior? |
Continue, transform the text, or handle it |
before_agent_start |
prompt, images?, systemPrompt, systemPromptOptions |
Inject a message or replace the prompt |
tool_call |
toolName, input, toolCallId |
Mutate event.input, or { block: true, reason } |
tool_result |
result | Replace content, details, structuredContent, isError, usage |
message_end |
message |
Replace the finalized message, keeping its role |
turn_end |
turnIndex, message, toolResults |
Append entries, return continue: true |
agent_before_settle |
boundary state | The same, as the last chance |
session_before_compact |
preparation, reason, willRetry, signal |
Cancel, or supply your own summary |
user_bash |
command, excludeFromContext, cwd |
Handle the command yourself |
The guard
Back to the force push. Here is the whole extension:
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
const BLOCKED = [
/\bgit\s+push\b[^\n]*(--force(?:-with-lease)?|-f)\b/,
/\bgit\s+reset\s+--hard\b/,
/\bgit\s+clean\b[^\n]*-[a-z]*f/,
];
export default function (pi: ExtensionAPI) {
pi.on("tool_call", async (event, ctx) => {
if (event.toolName !== "bash" && event.toolName !== "powershell") return;
const raw = JSON.stringify(event.input);
const hit = BLOCKED.find((re) => re.test(raw));
if (!hit) return;
if (!ctx.hasUI) {
// No way to ask. Refuse rather than allow silently.
return { block: true, reason: `Guard blocked: ${hit.source}` };
}
const ok = await ctx.ui.confirm(
"This command rewrites history or destroys work. Allow?",
raw.slice(0, 200),
);
if (ok) return;
return { block: true, reason: "Blocked by review-guard extension" };
});
}
Four things worth naming.
This guard reads event.input; it does not mutate it. Other handlers may change tool arguments by mutating event.input, while this handler uses the declared { block: true, reason } result to deny a matching call.
ctx.hasUI guards the dialog. extensions.md says to use ctx.hasUI for interactions supported by interactive and RPC clients, and ctx.mode === "tui" for terminal-only behaviour. A print-mode run has no UI, and the honest behaviour there is to refuse.
The regexes inspect the serialised input for the bash and powershell tools, so they can catch a matching command inside a longer string. They do not parse shell syntax, do not inspect other tools, and cannot cover every way to perform a destructive Git operation. Moreover, this example asks for confirmation and allows a matching call when the user approves it. That differs from the unconditional force-push prohibition in chapter 11; use a separate, non-overridable branch if your intended rule is “never force push.” Neither version is an operating-system security boundary.
block: true without a reason gives the model nothing to work with. Always supply one.
Errors fail differently
extensions.md distinguishes two error paths, and the asymmetry matters:
- A
tool_callhandler failure blocks the tool as a fail-safe. - A tool execution failure becomes an error result for the model.
So a guard extension that throws on every call will block every call. That is safe, and it is also a total outage. Catch inside the handler.
Concurrency rules
Three constraints from extensions.md that change how you write handlers:
- Tool calls from one assistant message can run in parallel. Do not assume a sibling call or result exists when another tool event runs.
- Use
ctx.signalfor nested work owned by an active turn; commands and idle session events often have no operation signal. - A
user_bashhandler returningundefinedpasses the command to the next handler and then to local execution if no handler handles it. Returningoperationsorresultstops propagation. A handler failure blocks the command rather than falling through.
Point 1 is the one that catches people. A guard that assumes it sees calls in order will race when one assistant message issues two tool calls at once. Write handlers to be independent.
Two events you should not rewrite
provider_stream_event fires for each parsed provider stream event before Pi normalizes it. extensions.md says to treat it as read-only because mutation can affect normalization, and that it is notification-only and not persisted. It is a debugging surface, not a control surface.
cache_warming_decision can override an idle prompt-cache refresh with { action: "warm" } or { action: "stop" }, and the last handler that returns an action wins. Note the rule: last wins, not first. Worth knowing before you stack two extensions that both answer it.
One extension, several hooks
A second guard example registers a status command and a tool_call handler. Its confirmation-based policy is illustrative, not an implementation of chapter 11’s unconditional force-push prohibition:
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export default function (pi: ExtensionAPI) {
let blockedThisRun = 0;
pi.registerCommand("guard-status", {
description: "Show how many calls review-guard has blocked this run",
handler: async (_args, ctx) => {
if (ctx.hasUI) {
ctx.ui.notify(`review-guard blocked ${blockedThisRun} call(s)`, "info");
}
},
});
pi.on("session_start", async () => {
blockedThisRun = 0;
});
pi.on("tool_call", async (event, ctx) => {
if (event.toolName !== "bash") return;
const raw = JSON.stringify(event.input);
if (!/\bgit\s+push\b[^\n]*(--force|-f)\b/.test(raw)) return;
const ok = ctx.hasUI
? await ctx.ui.confirm("Allow a force push?", raw.slice(0, 200))
: false;
if (ok) return;
blockedThisRun += 1;
return { block: true, reason: "Blocked by review-guard: force push requires --force-with-lease" };
});
}
Two lifecycle details matter. blockedThisRun is closure state established when the factory runs and reset by session_start; the reset, rather than module loading alone, gives the counter session scope. Handlers run in extension-load and registration order, but the fact that one sees a call first does not by itself make its policy an exclusive enforcement boundary.
Cleaning up
extensions.md asks for three things: release resources in session_shutdown even when normal operation already attempted cleanup; keep cleanup idempotent because cancellation, reload, session replacement, and process exit converge on the same path; and use ctx.shutdown() to request an orderly process shutdown.
Our guard holds no resources, so there is nothing to release. That is a legitimate outcome, and it is worth aiming for.
What was run
examples/ch16-events/ loads both guards into a real session and drives a scripted model that asks for bash. The commands include touch ran.flag, so “was it blocked?” is a file that exists or does not, not an inference:
- with no UI to ask, the force-push guard refuses, the result is an error the model can read, and
ran.flagis never created; - an ordinary command passes through and does run;
git reset --hardis refused by the same guard;- the multi-hook version refuses with the
--force-with-leasereason; - a
tool_callhandler that throws blocks the tool as a fail-safe, and again no file appears.
The last of those is the documented fail-safe, observed. It is also the reason to catch errors inside a guard: a guard that throws on every call is a total outage, not an open door.
What to verify
Use a disposable repository or an isolated test harness with a harmless stand-in command. Reload the extension, then make a scripted tool call whose command string matches git push --force but whose execution can be detected without contacting a remote. Verify both outcomes: a confirmation refusal blocks the call, and a no-UI invocation blocks it without asking.
Do not use a real git push --force against a live remote as the smoke test. -ne disables extensions, so it removes the guard rather than testing the guarded no-UI path. To test that negative control, use the same harmless stand-in inside the disposable test environment.
This handler observes matching tool_call events for the tools it names; it does not govern shell commands run by other mechanisms, and it cannot block an invocation that never reaches that event. Chapter 17 takes that apart: what registration actually guarantees, what exposure adds, and what a tool has to declare before a permission extension will let it through unattended.