Adding Herdr support to a Pi extension
I’d heard about Herdr before but never tried it. I already know tmux and didn’t want to learn yet another tool. Then I saw it used on a podcast in a way that looked like it could work for me. They also mentioned that you can ask an agent to port your config over from tmux, shortcuts and keymaps included, so there isn’t that much to relearn.
The same day or the day after, jokklan opened a pull request to add Herdr support to pi-guardrails. I took it as a sign, had an agent port my tmux config over, and tried it.
A couple hours later, I realized it matched my workflow and how my brain works. I especially like seeing the status of agents across different projects at once, including the long-running ones that don’t need me to babysit them.
A Guardrails approval prompt is exactly when I want Herdr to show a pane as blocked: the agent needs me, and I should be able to see that without opening the pane. pi-guardrails v0.16.0 adds that support.
Guardrails now shows up as blocked
As of v0.16.0, pi-guardrails ships a fourth extension, extensions/herdr/index.ts.PR #74 is the version that shipped. While a permission-gate or path-access prompt waits for an answer, the Herdr pane list shows the pane as blocked, with the label “Guardrails approval required”.
Here’s what that looks like:I’ve been letting agents record these videos on my desktop or edit them afterwards. They get most of it right now, with caveats. GLM-5.2 handled this one except for zooming on the indicators, so I had it build an HTML editor where I could mark what to zoom and when.
How it works
Herdr installs a Pi extension
During onboarding, Herdr asks which coding agents you use and offers to install an integration for each of them (docs). You can also install it for Pi by hand:
herdr integration install pi
The integration is a single extension file, installed globally next to your other Pi extensions. It is not injected per session: Pi loads it in every session, and the file itself decides whether it should be active by checking for environment variables that Herdr only sets inside its own panes:
const HERDR_ENV = process.env.HERDR_ENV;
const socketPath = process.env.HERDR_SOCKET_PATH;
const paneId = process.env.HERDR_PANE_ID;
function enabled() {
return HERDR_ENV === "1" && !!socketPath && !!paneId;
}
export default function (pi) {
if (!enabled()) {
return;
}
// ...
}
The extension owns the pane’s agent state
Inside a Herdr pane, the extension reports three states: working, idle, and blocked. The first two come from Pi’s own agent_start / agent_settled hooks. But Pi has no hook for “waiting on a human”, so blocked comes from a custom event on Pi’s event bus that anyone can emit:
pi.events.on("herdr:blocked", (data) => {
if (!data?.active) {
blockedCount = Math.max(0, blockedCount - 1);
if (blockedCount === 0) {
blockedMessage = undefined;
}
publishState();
return;
}
blockedCount += 1;
blockedMessage = data.label;
publishState();
});
Herdr expects { active: true, label } when something blocks and { active: false } when it clears. There is no schema enforcement on the event bus, so you just cross your fingers that both sides agree. Herdr also counts active blocks instead of storing a boolean. If two prompts are open, closing one should not make the pane look unblocked.
Blocked also outranks the two lifecycle states:
function desiredState() {
if (blockedCount > 0) {
return { state: "blocked", message: blockedMessage };
}
if (agentActive) {
return { state: "working", message: undefined };
}
return { state: "idle", message: undefined };
}
From the event bus to the Herdr socket
When the state changes, the extension writes it as a JSON line into the unix socket Herdr exposes for the pane. The socket API reference has the full protocol. The extension tries once, retries once, then gives up so it never stalls Pi.
Herdr calls Pi a “lifecycle authority” integration (integrations, agents). While the extension is reporting, Herdr stops guessing from what is on the screen and trusts these reports instead. The pane shows a red dot for blocked, a spinner for working, and a check for idle.
The Guardrails adapter
Guardrails already emitted two prompt lifecycle events: guardrails:prompt:opened and guardrails:prompt:closed. The adapter listens to both and keeps a set of the prompt IDs it has seen. That stops duplicate events from throwing Herdr’s counter off, and closing one prompt won’t clear another one that is still open.
export default function herdr(pi: ExtensionAPI): void {
const activePrompts = new Set<string>();
pi.events.on(GUARDRAILS_PROMPT_OPENED_EVENT, (data) => {
const id = (data as GuardrailsPromptOpenedPayload)?.prompt?.id;
if (!id || activePrompts.has(id)) return;
activePrompts.add(id);
pi.events.emit("herdr:blocked", {
active: true,
label: "Guardrails approval required",
});
});
pi.events.on(GUARDRAILS_PROMPT_CLOSED_EVENT, (data) => {
const id = (data as GuardrailsPromptClosedPayload)?.prompt?.id;
if (!id || !activePrompts.delete(id)) return;
pi.events.emit("herdr:blocked", { active: false });
});
}
There is no Herdr import in Guardrails. Without Herdr’s Pi integration loaded, nobody listens to these events and nothing happens.
Doing it in your own extension
If your own extension waits on the user, emit { active: true, label } when it starts waiting and { active: false } when it stops. Just make sure the pairs balance.
For example, you could show when a run ended in an error:
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
const HERDR_BLOCKED_EVENT = "herdr:blocked";
export default function errorState(pi: ExtensionAPI): void {
let blocked = false;
const block = (label: string) => {
if (blocked) return;
blocked = true;
pi.events.emit(HERDR_BLOCKED_EVENT, { active: true, label });
};
const unblock = () => {
if (!blocked) return;
blocked = false;
pi.events.emit(HERDR_BLOCKED_EVENT, { active: false });
};
pi.on("agent_end", (event) => {
const last = event.messages.findLast((m) => m.role === "assistant");
if (last?.stopReason !== "error") return;
block("Agent run failed");
});
// A retry starts a new agent run, so the pane is no longer waiting on me.
pi.on("agent_start", unblock);
// Nothing follows the last failed retry, so clear it when the session settles.
pi.on("agent_settled", unblock);
// The pane goes away with the session anyway, but cleaning up after
// yourself is good hygiene.
pi.on("session_shutdown", unblock);
}
getLastAssistantText() would be convenient here, but extensions cannot access it and I need the stopReason, not the text. agent_end already has the messages for that run.
Pi retries errors three times with exponential backoff by default. Each retry emits agent_start, which clears the block while Pi tries again. agent_settled handles the final failure, and session_shutdown is there because cleaning up your own state is a good habit.
The adapter ships with pi-guardrails v0.16.0. Install it with pi install npm:@aliou/pi-guardrails; Herdr installs its Pi side during onboarding.