Persist a guard's block count as a session entry
Log a blocked write with pi.appendEntry(), read it back with /guard-blocks, and watch the count follow the branch.
A module-level let dies with the process: /reload and resume both reset it. The
fix is to use the durable store Pi already hands you. This example logs every
blocked write as a custom session entry that the model never sees, and rebuilds
the count from the active branch at session_start and on navigation. The claims
are observed over a scripted model and a recording UI.
Run it
Use Pi 1.0.4 and one of the example repository’s test runners. The file below
is an excerpt of the chapter’s canonical guard-blocks.ts:
pi.on("tool_call", async (event, ctx) => {
if (event.toolName !== "write" && event.toolName !== "edit") return undefined;
const path = String(event.input.path ?? "");
const hit = PROTECTED.find((p) => path.includes(p));
if (!hit) return undefined;
const entry: GuardBlock = {
tool: event.toolName,
reason: `path matches ${hit}`,
at: Date.now(),
};
blocks = [...blocks, entry];
// Durable, branch-aware, and invisible to the model.
pi.appendEntry<GuardBlock>("guard.block", entry);
if (ctx.hasUI) ctx.ui.notify(`Blocked ${event.toolName} on ${path}`, "warning");
return { block: true, reason: `Path "${path}" is protected` };
});
pi.registerCommand("guard-blocks", {
description: "List blocked tool calls recorded in this session",
handler: async (_args, ctx) => {
ctx.ui.notify(
blocks.length === 0 ? "No blocked calls in this session" : blocks.map((b) => b.reason).join("; "),
"info",
);
},
});The in-memory blocks array is a cache, not the store. Refill it from the active
branch, never from every entry in the file — abandoned branches are alternative
histories, and their blocks must not count:
// The module variable is a cache, not the store. Refill it from the active
// branch, never from every entry in the file.
const rebuild = (ctx: ExtensionContext) => {
const next: GuardBlock[] = [];
for (const entry of ctx.sessionManager.getBranch()) {
if (entry.type === "custom" && entry.customType === "guard.block") {
next.push(entry.data as GuardBlock);
}
}
blocks = next;
};
pi.on("session_start", async (_e, ctx) => rebuild(ctx));
pi.on("session_tree", async (_e, ctx) => rebuild(ctx));Expected outcome
| Step | Result (observed) |
|---|---|
The model writes .env |
The write is blocked; the user sees a warning notification; a custom entry with customType "guard.block" lands in the session |
| The next request goes out | Neither the entry nor the reason text reaches the provider |
Run /guard-blocks |
It reports path matches .env |
| Navigate to before the block, run it again | No blocked calls in this session — the count is branch-relative |
Mechanism and limitations
Documented: pi.appendEntry(customType, data) writes a custom session entry;
the session-format doc states that a CustomEntry “does NOT participate in LLM
context”; extensions.md says to reconstruct branch-sensitive state from
ctx.sessionManager.getBranch() during session_start rather than from every file
entry. Observed: guard-blocks.test.ts drives the real extension through an
AgentSession with a scripted provider and a recording UI, asserting each row
above — including that the reason text is absent from the transcript the provider
was sent, the strongest form of “invisible to the model”.
This example does not show a real model, and it does not show the entries in the
transcript: rendering them is pi.registerEntryRenderer, which is interactive-only
and belongs to the next chapter. The count survives reload only because
/reload re-runs the factory and session_start rebuilds it — the factory itself
is not durable.
Understand this example
Copy this prompt into your AI tool. No code runs here.
Apply this example
Copy this prompt into your AI tool. No code runs here.