I’d had my eye on herdr — a pane-monitoring tool that tells you what your terminal tabs and panes are doing: which command is still running, which one finished while you weren’t looking, which agent is thinking. Exactly the awareness I wanted. But before installing it, I paused at the same question that keeps ruining tools for me: my terminal already knows all of this. WezTerm knows when a pane printed output I haven’t seen. Fish knows when a command starts and stops. My coding agent knows when it’s mid-turn. Why run a separate program to re-derive facts that the stack below it already owns?
So instead of adding herdr, I built the same awareness out of the tools I already run. No new processes, no polling daemon — ~100 lines spread across wezterm, fish and a pi extension.
Three states, three owners
I boiled down what I actually wanted to three indicators, one glyph slot per tab:
▶— a command is running●— a pane has output I haven’t seen yet— an animated spinner when pi is thinking
The whole trick is that each indicator comes from the layer that natively knows it, and they meet in exactly one place: OSC 13371 user variables. OSC 1337 is an escape-sequence convention for pass-through metadata — set a name/value pair from inside any pane and wezterm exposes it to its Lua as pane.user_vars. So every producer writes its state as a variable — printf in fish, one process.stdout.write in pi — and wezterm reads the variables and picks the single glyph to show.
WezTerm renders the tab bar and exposes a format-tab-title hook that can read each pane’s user variables. The hook is dumb on purpose: spinner holding a braille frame shows the animated , running holding a command name shows ▶ plus the command as the tab label, attention=1 shows ● via a tiny seen-map — switching to the tab marks it seen. No polling, no daemon, no string parsing:
local function live(v)
return v and v ~= '' and v ~= '0'
end
-- attention seen-map: var ~= '1' resets it (fresh toggle per run)
if vars.attention ~= '1' then
attention_seen[pane.pane_id] = nil
end
-- priority: spinner > ▶ running > ● attention — mutually exclusive by lifecycle
local indicator
if live(vars.spinner) then
indicator = vars.spinner
elseif live(vars.running) then
base = vars.running
indicator = nf.md_play
elseif vars.attention == '1' then
if tab.is_active then
attention_seen[pane.pane_id] = true
elseif not attention_seen[pane.pane_id] then
indicator = nf.md_record
end
end
Fish knows execution boundaries, and fish_preexec/fish_postexec give it two clean hooks per command — the same shape as the var lifecycle itself. On start: attention=0, running=<command>. On end: running=0, and attention=1 if the command ran longer than ten seconds:
function _running_start --on-event fish_preexec
set -l cmd (string split ' ' -- $argv[1])[1]
printf '\e]1337;SetUserVar=attention=MA==\a' # base64("0")
if contains -- $cmd lazygit hx pi nchat htop btop ssh fish vim nvim less man
printf '\e]1337;SetUserVar=running=MA==\a' # base64("0")
else
printf '\e]1337;SetUserVar=running=%s\a' (string join '' (echo -n $cmd | base64))
end
end
function _running_end --on-event fish_postexec
printf '\e]1337;SetUserVar=running=MA==\a' # base64("0")
if test $CMD_DURATION -gt 10000
printf '\e]1337;SetUserVar=attention=MQ==\a' # base64("1")
end
end
Pi agent knows turn state. It ships as an extensible core: a ~30-line TypeScript extension hooks agent_start/agent_settled — the same two events fish has. On start it sets attention=0; while working it animates a spinner var; on settle it clears spinner and sets attention=1, so an unfocused tab shows the dot until I look at it. Each var write is one escape sequence:
function setVar(name: string, value: string) {
process.stdout.write(`\x1b]1337;SetUserVar=${name}=${Buffer.from(value).toString("base64")}\x07`);
}
And that’s the integration protocol: OSC 1337 user variables for state, the title string for labels. Boring, ancient. Fish writes running and attention, pi writes spinner and attention, wezterm reads the vars and decides the single glyph. Each side can be replaced without the others noticing — neither producer knows the other exists, and the renderer never parses a producer’s strings.
Producers own their exceptions
Interactive apps (lazygit, helix, htop…) poll their own UI and would trip every indicator forever. With vars as the protocol, the question dissolves: the producer that knows what it’s about to run simply doesn’t raise the flag. Fish’s preexec checks one contains list and writes running=0 for interactive commands; no ▶, no ●, and the wezterm side never hears about them.
That leaves the priority chain trivial — spinner beats ▶ beats ● beats blank — not because the renderer enforces it, but because the var lifecycles can’t overlap: running is already 0 by the time attention fires. sleep 30 shows ▶sleep, then ● until I look at the tab. One glyph, decided before the render runs.
Emergent effects
The composition turned out to be more than the sum of its parts — two payoffs I didn’t plan when building the indicators.
The first goes outward: because the pieces compose through plain wezterm primitives, an agent can reach other panes too. WezTerm can spawn panes programmatically, and pi carries a panes skill backed by a small registry (~/.pi/panes) that maps every pi pane to its session and working directory. So I now allow pi to open a new pane without asking, start a dedicated pi for a specific task there, let it run unattended, and later read the result back from that pane’s scrollback — one agent delegating to another, with no protocol designed for it. Or the more common direction: “I ran the migration in pane 3, check the result.” Pi reads that pane’s terminal output directly. herdr gives you monitoring of agents; here the same seam that powers the indicators gives the agent first-class access to other terminals.
Few days later, a second payoff appeared — from the consumer side. Because wezterm now persistently knows which panes are busy, it can do more than draw glyphs: it can act on that fact. The most useful one: while any pane is working — a long command, an agent mid-turn — the Mac simply doesn’t sleep. Even with the lid closed, even on battery. I close the laptop, head somewhere else, and the agent keeps proceeding; by the time I sit down again, the work is done and the ● is waiting on the tab.
The punchline
The debugging took a while (at one point I was convinced a tab-bar hook was breaking an in-pane spinner — turns out I had deleted the extension that drew it, months ago, and forgotten). But the code is tiny:
- ~55 lines of Lua in
.wezterm.lua - ~15 lines of fish
- ~30 lines of pi extension
Compare that with what a dedicated tool brings: its own process, its own UI, its own keybindings, its own idea of what your panes are. Here, every fact is owned by the layer that generates it, the protocol between them is a string, and the total surface area is smaller than the config file of a monitoring utility.
WezTerm, fish and pi all expose the right seams, so the pieces compose instead of compete. A hundred lines, no new process, and the tab bar now tells me what it’s doing.
Written with LLM assistance; code is real and used.
-
OSC 1337 is an iTerm2 escape-sequence convention, not a standard — wezterm implements a useful subset of it (including
SetUserVar). It’s not universal — kitty defines its own sequence set instead — so the trick travels only to terminals that speak it. ↩