# Writing a pi extension that tells you when the agent is done

> Source: <https://dev.to/beasaltfish/writing-a-pi-extension-that-tells-you-when-the-agent-is-done-1eel>
> Published: 2026-11-12 10:20:22+00:00

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 ; <title> ; <body> BEL
```

`ESC ]` starts an Operating System Command. `777` is a number that rxvt-unicode picked years ago for notifications, and Ghostty, WezTerm, Warp and others copied it. Kitty uses its own `99`. There is no standards body behind any of this. A terminal that doesn't recognise the number drops the sequence silently, and you get nothing.

So which channel to use depends on two things, not one: whether pi is on the same machine as you, and whether a terminal multiplexer sits in between.

| Situation | Channel | Why | 
|---|---|---|
| Local, no multiplexer | OSC escape sequence | the terminal shows it | 
| Local, unknown terminal | system notification | Apple Terminal and friends ignore OSC | 
| Local tmux | system notification | tmux swallows OSC unless you configure it | 
| SSH, no multiplexer | OSC | a system notification would fire on the remote box | 
| SSH + tmux | OSC wrapped in DCS | both layers have to be passed | 

The SSH row is the one people get wrong. `osascript` and `notify-send` run wherever the process runs. Over SSH, that's the machine you're not sitting at. The escape sequence, on the other hand, travels in the output stream and comes out of the terminal in front of you.

The tmux row is the opposite trap. tmux redraws the screen itself and only forwards the sequences it understands, so `777` dies there. Locally the simplest answer is to skip OSC and call the OS API directly, which needs no tmux config. Remotely you can't do that, so the sequence has to be wrapped:

```
ESC P tmux ; ESC ] 777 ; notify ; ... ESC \ ESC \
```

That requires `set -g allow-passthrough on` in tmux config. It's the one place where the extension asks you to change something.

I tested Warp and Ghostty myself, end to end, on macOS. Everything else in the table is a statement about the mechanism each one supports, not a claim that I ran it.

| Terminal | Channel | 
|---|---|
| Warp | OSC 777 | 
| Ghostty | OSC 777 | 
| iTerm2 | OSC 777 | 
| WezTerm | OSC 777 | 
| rxvt-unicode | OSC 777 | 
| kitty | OSC 99 | 
| Apple Terminal | system notification | 
| Alacritty | system notification | 
| VS Code integrated | OSC 777, unverified | 
| Windows Terminal | PowerShell toast | 
| tmux / screen (local) | system notification | 
| tmux over SSH | OSC + DCS passthrough | 

The VS Code row says unverified in the README too. I'd rather leave a gap in a table than have someone install it and wonder why nothing happened.

Even with the right channel, the notification may be invisible — because you're looking at it.

Banners are suppressed for the app that has focus, and terminals clear their own delivered notifications when the window comes back to the foreground. During the first test in Ghostty, nothing appeared, so I assumed the code was broken. The system log said otherwise:

```
Requested authorization [ didGrant: 1 ]
Adding notification request D528-212C
Added notification request: [ hasError: 0 ]
Removing 1 delivered notifications
```

Delivered, then removed three seconds later. The notification only stays up when you're not in the terminal — which is, of course, the situation the whole thing is for. The first opt-in prompt also has to be answered once; before that, macOS refuses to deliver anything at all, and it refuses quietly.

OSC has no acknowledgement. You write bytes and hope. That makes the integration untestable in the usual sense, so I split it: everything that can be a pure function took an argument, and only two functions touch the outside world.

`decideChannel(env, platform)` takes an environment object instead of reading `process.env`, and returns a plain object describing what to do. Same for the text formatting and the run state machine. That's the whole design constraint, and it's why the test suite needs no mocking library.

The tests run on `node --test` with TypeScript source. Node 24 strips the types, so there's no build step and no test framework:

```
node --test "test/**/*.test.ts"
```

45 tests, no dependencies. One thing that cost me ten minutes: `node --test test/` treats the directory as a module and fails. The quoted glob is the version that works, on every platform.

The remaining two functions write to stdout and spawn a process. Those I tested by hand, in two terminals, with the video recording as evidence.

pi runs in four modes: `tui`, `rpc`, `json`, `print`. In RPC mode, stdout is a JSON stream — the same stdout the escape sequence would go to. Writing a notification there would corrupt the protocol for whoever is reading it.

So the extension checks `ctx.mode === "tui"` before writing anything, and stays silent everywhere else. In print and JSON modes the caller reads the result anyway, and in RPC the caller is usually an editor that shows the result itself.

I got this wrong in the plan and only noticed when checking the design against the notes. It's a one-line guard, but it's the kind of line that turns into a bug report titled "your extension broke my integration".

There's no submission form for the pi package gallery. The `pi-package` keyword in `package.json` is the submission:

```
{
  "name": "pi-bell",
  "keywords": ["pi-package"],
  "pi": {
    "extensions": ["./src/index.ts"],
    "video": "https://raw.githubusercontent.com/beasaltfish/pi-bell/master/demo.mp4"
  }
}
```

The `pi` key is the manifest — it tells pi where the entry point is, and the optional `image` / `video` fields give the gallery something to show. Nothing else is needed.

The gallery list itself is a periodic snapshot, so a package published today shows up in search later, sometimes a day later. The detail page at `pi.dev/packages/<name>` works immediately, which makes the delay look weirder than it is.

The code is about 300 lines across five files, and roughly a third of it is the terminal detection. If you're building something on pi and only take one thing from this: check which event you're subscribing to. `agent_end` is not the end.

pi-bell is on [GitHub](https://github.com/beasaltfish/pi-bell) and [npm](https://www.npmjs.com/package/pi-bell). If you try it in a terminal that isn't in the table above, tell me what happened — that's how the table gets better.

I'm [@0xasten](https://x.com/0xasten) on X, where I write about building things.
