Herdr shows live progress for recognized coding agents. A pane running
cargo build --release for four minutes shows nothing at all.
This plugin fixes that: slow shell commands report working with a ticking
elapsed label, and leave a result behind when they finish.
ls leaves no trace; sleep 6 crosses the threshold and appears in the
sidebar as sleep 6 · running 4s, then settles into sleep 6 · ok · 6s.
- Fast commands are invisible — nothing flickers when you run
ls. - Failures stick until your next command, so you see what broke while away.
- Successes clear themselves after 20 seconds by default.
- A keylock session that is locked wears a 🔒 while it runs, so you can see the pane is protected.
- Zero socket traffic and zero output for commands under the threshold — with one exception: the first command after a sticky failure label spends two requests wiping it, however fast that command is.
keylock runs a command behind an input lock: while the session is locked it drops every key, so a stray Space or Ctrl+C cannot interrupt a long migration. Nothing in Herdr's own chrome shows that a pane is locked. This plugin already owns that sidebar row, so it shows it here.
While a tracked command is keylock and its session is locked, the row wears
the lock:
🔒 keylock run -- ./migrate.sh · running 4m12s
The caption at the bottom names each key as it is pressed, and whether it got
through. The job aborts on any keypress, so the counter running on is the
proof: while the session is locked every key — a, Space, even Ctrl+C — is
dropped, and the row wears 🔒. The phrase's own letters are dropped like any
other key too, right up to the one that completes it; the lock leaves the row
within a tick, and the next key then reaches the job, which aborts as it would
have all along.
The check runs once per tick, only for keylock commands, and asks keylock
itself (keylock status --pane) rather than guessing from the command line.
It needs keylock 0.2.0 or newer. Configure or disable it with [lock] in
config.toml; an empty prefix turns off both the marker and the check.
Point HSP_KEYLOCK_BIN at a different binary to override which keylock the
probe runs — mainly useful for debugging.
- Herdr >= 0.7.0
- zsh, bash >= 3.2, or fish >= 3.1 — each has its own hook file under
shell/. Any other shell needs a port; see Porting to another shell. - A Rust toolchain — installation compiles the watcher from source. There are no prebuilt binaries.
- macOS or Linux. Developed and tested on macOS; the Rust and shell sides are all portable and Linux should work, but it has not been run there. Reports welcome.
herdr plugin install bayoudhi/herdr-shell-progressThat runs cargo build --release for you.
Now add the hook to your shell's rc file. Installed plugins live under a content-hashed directory, so each snippet matches it with a glob and takes the newest, rather than hardcoding a path that changes on every update. If the plugin isn't installed, they do nothing.
zsh — append to ~/.zshrc:
# herdr-shell-progress
() {
local f=(~/.config/herdr/plugins/github/bayoudhi.shell-progress-*/shell/init.zsh(Nom))
(( $#f )) && source $f[1]
}This uses zsh glob qualifiers rather than a subshell, so it costs no fork: N
makes a missing match empty instead of an error, and om orders newest-first.
bash — append to ~/.bashrc:
# herdr-shell-progress
_hsp_dir=$(ls -td ~/.config/herdr/plugins/github/bayoudhi.shell-progress-*/ 2>/dev/null | head -1)
[ -r "$_hsp_dir/shell/init.bash" ] && . "$_hsp_dir/shell/init.bash"
unset _hsp_dirfish — append to ~/.config/fish/config.fish:
# herdr-shell-progress
for d in (ls -td ~/.config/herdr/plugins/github/bayoudhi.shell-progress-*/ 2>/dev/null)
if test -r $d/shell/init.fish
source $d/shell/init.fish
break
end
endThen open a new pane — an rc file only runs for new shells — and run
sleep 5.
That block is required. Installing the plugin alone does nothing: Herdr can run a plugin's own processes, but only your shell knows when a command starts and stops, so the hooks have to live in your shell.
If something already loaded bash-preexec — Atuin and iTerm2's shell
integration both do — the hook registers with it rather than installing a
second DEBUG trap, which would start two watchers per command. Nothing is
needed from you beyond loading bash-preexec before this snippet.
If some other DEBUG trap is installed and bash-preexec is not there to
share it, the hook stays inert rather than breaking whatever owns the trap.
Loading bash-preexec first makes both work.
One consequence of going through bash-preexec: it hands over the command line but not its alias-expanded form, so an aliased command reaches the ignore list under the alias rather than the program behind it. See Ignoring commands.
Installed from a clone, or want to check the path by hand?
For a plugin link install, source your clone directly:
source ~/herdr-shell-progress/shell/init.zsh # or init.bash, or init.fishTo see the resolved path for an installed copy:
herdr plugin list --json \
| python3 -c 'import json,sys;print(next(p["plugin_root"] for p in json.load(sys.stdin)["result"]["plugins"] if p["plugin_id"]=="bayoudhi.shell-progress"))'There is also a print-snippet action, but note that
herdr plugin action invoke returns an invocation record on stdout and sends
the action's own output to the plugin log — so you would need
herdr plugin log list --plugin bayoudhi.shell-progress to read it. The glob
above avoids that entirely.
git clone https://github.com/bayoudhi/herdr-shell-progress ~/herdr-shell-progress
cd ~/herdr-shell-progress
cargo build --release
herdr plugin link ~/herdr-shell-progress
echo 'source ~/herdr-shell-progress/shell/init.zsh' >> ~/.zshrc # init.bash / init.fishherdr plugin link deliberately does not run build commands, so the
cargo build is required here.
herdr plugin config-dir bayoudhi.shell-progressCopy config.example.toml into that directory as config.toml. Every key is
optional. Changes take effect on your next command — no reload, no restart.
Some commands should never be reported: interactive programs that legitimately run for hours, and above all coding-agent CLIs. If this plugin reports on a pane running an agent, it and Herdr's own integration both try to own that pane's state, and yours wins — hiding what the pane actually is.
Two keys control this, and the difference matters:
# ADDS to the defaults. This is almost certainly the one you want.
ignore_extra = ["claude-personal", "terraform", "docker"]
# REPLACES the defaults entirely. Setting this drops every built-in entry,
# including the agent CLIs, unless you list them again yourself.
# ignore = ["vim", "less"]Matching is on the basename of the program name, exactly. The command is
read after alias expansion, and leading VAR=value assignments plus the
transparent wrappers command, builtin, exec, env and nohup are looked
through. So an alias like
alias claude-personal='CLAUDE_CONFIG_DIR=~/.claude-personal command claude'matches the built-in claude entry without any configuration.
zsh gets the expanded form from preexec and bash from $BASH_COMMAND. fish
needs no expansion — its abbreviations are already expanded in the submitted
line, and its functions carry the name you would think to ignore.
The exception is bash going through bash-preexec, which hands over the command line but not its expanded form. There, an alias reaches the ignore list under its own name, so list the alias as well:
ignore_extra = ["claude-personal"]A wrapper script is different: it is a real program, and the agent it runs is invisible from the outside, so it needs its own entry. Same for a renamed build. If a pane shows a long "running" label for something that isn't really a shell command — or the pane's own agent looks like it has been taken over — this is why:
printf 'ignore_extra = ["my-wrapper"]\n' \
>> "$(herdr plugin config-dir bayoudhi.shell-progress)/config.toml"It applies on your next command — no reload, no restart.
Defaults: vim, nvim, less, man, ssh, top, htop, zsh, bash,
sh, fish, claude, codex, opencode, droid.
They appear in the sidebar's agents list, alongside your coding agents. That is simply where Herdr shows pane status; a plugin cannot add a section of its own, and there is no setting to filter or separate them.
What the plugin does do is report a constant agent id, shell, for every
command. Without that, each distinct command would mint its own agent identity
and your list would fill up with cargo, sleep, make, and every other
binary you ever ran. One id keeps it to a single kind of entry. The row still
shows the actual command — the line as you typed it, so npm run start rather
than a bare npm — sent as display_agent, which Herdr renders in preference
to the id.
Row names are cut at max_display_len (24 characters by default) with an …
marking the cut, and runs of whitespace are collapsed so the row stays tight.
Raise or lower it in config.toml:
max_display_len = 32Keep it under Herdr's own ui.sidebar_max_width, which defaults to 36 columns.
The row spends columns on the state icon and the elapsed label too, so a wider
cap does not widen the name — Herdr truncates it again, and the elapsed label is
what gets squeezed out.
The ignore list is unaffected by any of this: it still matches on the program
name alone, as does the {agent} label variable.
These entries cannot be restyled. Herdr's rows_by_agent table is validated
against its own canonical agent ids (claude, codex, gemini, and so on) and
rejects anything else. It rejects it by refusing to parse the entire config
file, so adding a rule for this plugin does not merely fail to apply — Herdr
silently falls back to default settings and you lose your keybindings and theme
until you remove it. An earlier version of this README recommended exactly that;
if you followed it, delete the rule and run herdr config check.
If you would rather a command never appear at all, that is what
ignore_extra is for.
Herdr's default sidebar rows are:
rows = [["state_icon", "workspace", "tab"], ["agent"]]There is no state_text in there, so out of the box you get the spinner and the
command line but not the running 4s label — the plugin reports it, and
nothing displays it. To see what the demo above shows, add state_text to the
second row in your Herdr config.toml:
[ui.sidebar.agents]
rows = [["state_icon", "workspace", "tab"], ["agent", "state_text"]]Then herdr config check and herdr server reload-config. This is the plain
rows key, which accepts any of Herdr's built-in row fields — unlike
rows_by_agent above, it is not restricted to canonical agent ids.
This also affects the finish labels: ok · 4s, exit 1 · 12s, and SIGINT · 3s
all live in state_text.
The pre-command hook spawns a detached watcher. The watcher sleeps until the
threshold; if the command finishes first it exits having never touched the
socket. Otherwise it reports via pane.report_agent and ticks
pane.report_metadata. The pre-prompt hook writes the exit code and signals the
watcher, which posts the final label and exits.
Under zsh that costs one fork per prompt — the watcher itself — because
everything else is a builtin. bash pays a second fork to read history 1, and
fish a second fork for kill, which it has no builtin for.
The watcher writes nothing to stdout or stderr — it inherits the pane's tty, so any output would corrupt your shell session.
The Rust watcher is shell-agnostic. Everything shell-specific lives in
shell/init.zsh, shell/init.bash and shell/init.fish, each about 50 to 100
lines, and a port needs to do three things:
- On command start: write the command line to
<state-dir>/cmd, then spawnherdr-shell-progress watch --pane "$HERDR_PANE_ID" --shell-pid <shell pid> --start-ms <epoch ms> --state-dir <state-dir>, detached, with both streams redirected to/dev/null. Pass--clear-firstif<state-dir>/markerexists. A shell with no cheap millisecond clock can pass--start-nowinstead of--start-msand let the watcher read its own. - On command end: write
$?to<state-dir>/exit, then sendSIGUSR1to the watcher. - On shell exit: send
SIGTERMto the watcher.
There is one optional fourth: a shell whose pre-command hook cannot see both the
whole command line and its alias-expanded form — bash is the only one so far —
can write the expanded leading command to <state-dir>/name, and the watcher
will match the ignore list against that instead of against cmd. The file is
consumed on read.
New hooks belong in tests/hooks.rs, which drives each one through a real
interactive shell on a pty and checks what it spawned. PRs welcome.
herdr plugin uninstall bayoudhi.shell-progressUse herdr plugin unlink bayoudhi.shell-progress instead if you installed from
a clone with plugin link.
Then remove the block from your shell's rc file. It is what actually runs the
plugin, so leaving it behind after uninstalling leaves a dangling source that
your shell will complain about on every new pane.

