← 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.

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_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 { 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_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 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.flag is never created;
  • an ordinary command passes through and does run;
  • git reset --hard is refused by the same guard;
  • the multi-hook version refuses with the --force-with-lease reason;
  • a tool_call handler 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.