← Pi Agents

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.

Only a tool_call handler can do that, because tool_call fires before the call runs. Getting that 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 are awaited in stream order, so a slow handler delays stream consumption. Handler errors are reported without changing the provider response — with one exception noted below.

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
  H-->>P: entries, and continue true
  P->>H: agent_settled
  Note over H: continue true buys one more request
  

Every arrow there is documented. What is deliberately absent is everything Pi does not place: provider_stream_event, session_before_compact, user_bash and cache_warming_decision have no stated position in the run, so they stay out of the picture and appear in the table below instead.

Two boundaries close the sequence:

  • agent_before_settle is the final actionable boundary: it can append entries and request one continuation.
  • agent_settled is 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 "yes", "no", or "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|-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.

event.input is mutated in place rather than replaced — ToolCallEventResult says to modify arguments by mutating event.input, and the result type carries only block, reason, and terminate.

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 match against the whole serialised input, which catches a force push buried in a longer command. That is a blunt instrument; a tool-call blocklist is a heuristic, and the honest framing is that it raises the cost of an accident rather than preventing a determined one.

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_call handler 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:

  1. Tool calls from one assistant message can run in parallel. Do not assume a sibling call or result exists when another tool event runs.
  2. Use ctx.signal for nested work owned by an active turn; commands and idle session events often have no operation signal.
  3. A user_bash handler returning undefined passes the command to the next handler and then to local execution if no handler handles it. Returning operations or result stops 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 realistic guard registers a command, an event, and some UI:

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) => {
			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 ordering details. The counter resets in session_start, not in the factory — a module-level variable initialised in the factory survives until reload, but session_start fires once per session, which is the granularity you want. And because handlers run in load and registration order, if another extension also guards bash, whichever loaded first sees the call first.

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 to verify

/reload
Ask: "push my branch forcefully"
Expected: the guard asks, and blocking reports a reason.

Then test the no-UI path, which is the one most likely to be wrong:

pi -ne --print "run git push --force origin main"

With -ne disabling extensions this will not be guarded — which is exactly the point of testing both paths. Remove -ne to check the print-mode branch of your own guard.

The guard intercepts calls that happen. It says nothing about calls that never happen, because a tool the model cannot see produces no tool_call event to intercept. 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.