Writing a pi extension that tells you when the agent is done A developer built pi-bell, an MIT-licensed extension for the open-source terminal coding agent pi that fires a desktop notification when a run truly finishes. The extension listens for the agent_settled event rather than agent_end, which fires after each low-level retry, compaction or queued follow-up and caused spurious notifications, and it classifies outcomes as done, cancelled or failed by reading stopReason and errorMessage from the last assistant message. It installs via `pi install npm:pi-bell`. I keep switching away while pi works. It's a long edit, or a test suite, and there's nothing to watch, so I go read something else. Then I come back to a prompt that finished four minutes ago. The other direction is worse: it died on a rate limit right after I looked away, and I found out much later. pi is an open-source MIT coding agent that runs in your terminal — the same shape as Claude Code or Codex CLI, minus the vendor lock-in: it works with models from many providers, and its extension API is a couple of TypeScript files rather than a plugin framework. pi does report its state to the terminal — working, blocked, done, failed — but that's an escape sequence too, and almost nothing implements it yet. Most of us are in Warp or Ghostty or iTerm2, which don't. So I wrote a small extension that puts a real desktop notification up when a run ends. The extension is pi-bell https://github.com/beasaltfish/pi-bell . It installs with pi install npm:pi-bell . Below is what I'd tell someone starting the same thing, mostly the parts I got wrong first. A pi extension is a file that default-exports a function. That function gets an API object, and everything else is subscribing to events on it. python import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; export default function pi: ExtensionAPI { pi.on "agent start", = { // a run began } ; pi.on "agent settled", event, ctx = { // it's over, and it isn't coming back } ; } That's the whole shape. Load it with pi -e ./my-extension.ts for one session, drop it in ~/.pi/agent/extensions/ to keep it, or publish it to npm and let people install it. agent end reads like the end of the run. It isn't. A session-level run in pi can contain several low-level runs. When the model errors out, pi retries. When the context overflows, it compacts and retries. When you queue a follow-up while it's working, that starts another one. agent end fires at the end of each of those, not at the end of the thing you're waiting for. I found this the annoying way. One prompt, three notifications, the third arriving while the agent was still going. That's the whole reason the extension exists in the form it does. The event you want is agent settled . It fires when pi has no further automatic work for that run — after retries, after compaction retries, after queued messages. It also carries aborted , which tells you whether the run ended because you pressed Escape. One nuance while you're here: agent before settle fires just before it, and it's the last point where an extension can still add context and ask for one more turn. If you only want to observe, agent settled is the one to use. If you want to influence, that's the other one. "Finished" is not the whole story. A run can end because the model was done, because you cancelled it, or because something broke. Those should not look the same at a glance, so the notification picks a title and a colour from a small state machine. The information is in the last assistant message. message end fires for every message; filter for role === "assistant" and read stopReason and errorMessage . stopReason is one of stop , length , toolUse , error , aborted . Keep the last one you see — each retry attempt overwrites it, which is what you want. Then decide, in this order: aborted event.aborted → cancelled stopReason === "error" → failed, with the first line of the message stopReason === "aborted" → cancelled stopReason === "length" → done, with a note that it was cut off everything else → done The order matters. If you hit Escape while a failing request is in flight, both signals can be present at once, and the honest answer is that you cancelled it. There's a gap I couldn't close. If the run dies before the model ever replies — bad API key, DNS failure — there is no assistant message, so there's no stopReason to read, and the run gets reported as finished. I could guess from ctx.getContextUsage or from provider errors, but a wrong "failed" is worse than a vague "done" here, so the README says so instead and I left it alone. This part took longer than the pi side. A desktop notification from a terminal is not a system call. It's a few bytes written to stdout, and the terminal decides what to do with them: ESC 777 ; notify ;