Herdr

A terminal multiplexer built for coding agents. It organises terminals into workspaces, tabs and panes, recognises the agent running inside a pane, and exposes the live session through the herdr CLI — that last part is the real difference from tmux: an agent can open its own pane, dispatch a command, and read the output back.

Website:

Install#

macOS / Linux:

sh
curl -fsSL https://herdr.dev/install.sh | sh

Windows:

powershell
powershell -ExecutionPolicy Bypass -c "irm https://herdr.dev/install.ps1 | iex"

The binary lands in ~/.local/bin/herdr. Note that a non-login shell usually does not have that directory on PATHcommand -v herdr over ssh will come back empty even when it is installed. Don't read that as "not installed":

sh
ssh myhost 'command -v herdr'                                  # may be emptyssh myhost 'export PATH=$PATH:~/.local/bin; herdr --version'   # this is the real check

Updates and channels:

sh
herdr updateherdr channel show          # stable / previewherdr channel set preview

Config#

~/.config/herdr/config.toml:

toml
onboarding = false
[ui]agent_panel_sort = "priority"
[theme]name = "terminal"auto_switch = false
[ui.toast]delivery = "system"

The same directory holds session.json (persisted layout), herdr.sock (API socket) and herdr-server.log.

Point it at pwsh on Windows#

A pane's shell defaults to $SHELL, which on Windows lands on the built-in Windows PowerShell 5.1 rather than PowerShell 7. To get 7 inside panes you have to say so:

toml
[terminal]default_shell = "pwsh.exe"

The documented behaviour is "when unset or empty, Herdr uses $SHELL, then /bin/sh on Unix and PowerShell on Windows" — and that Windows fallback is the built-in 5.1. The value is an executable name or path, not a shell command line.

Run herdr server reload-config afterwards, or just open a new pane. To check what you're actually in:

powershell
$PSVersionTable.PSVersion    # 5.1.x means it's the old one

Install 7 first if needed: winget install --id Microsoft.PowerShell. Note also that 5.1 and 7 have separate $PROFILE files (WindowsPowerShell\ vs PowerShell\), so anything configured in the old one does not carry over.

The other two [terminal] options#

shell_mode"auto" (default) / "login" / "non_login", controlling whether a new pane's shell starts as a login shell. The documentation spells out the reason: "auto" starts login shells on macOS so login-only PATH setup runs in new panes — things like /usr/libexec/path_helper and Homebrew's shell initialisation.

Worth remembering: on macOS path_helper reorders the system paths to the front, so "the PATH inside a pane differs from the one in my terminal" usually comes down to shell_mode.

new_cwd"follow" (default) / "home" / "current" / a fixed path such as "~/Projects". "follow" inherits the source pane or workspace; with no source, Herdr starts in $HOME.

Validate with herdr's own checker rather than by eye:

sh
herdr config check    # only "config: ok" counts

CLI#

Running bare herdr launches or attaches the TUI, so don't use it to explore commands. Print a command group instead:

sh
herdr --helpherdr pane            # prints the pane command groupherdr tabherdr workspaceherdr agent

Most commands return JSON. Read pane / tab / workspace ids out of the response rather than guessing them.

Running a command in a pane#

sh
# split to the right without stealing focusherdr pane split --current --direction right --cwd "$PWD" --no-focus# → .result.pane.pane_id
# dispatch, await, collectherdr pane run <pane_id> "pnpm build"herdr pane wait-output <pane_id> --regex "<marker>" --source visible --timeout 60000herdr pane read <pane_id> --source visible --lines 40

Herdr injects the caller's context into every managed pane:

sh
printf '%s\n' "$HERDR_WORKSPACE_ID" "$HERDR_TAB_ID" "$HERDR_PANE_ID"

HERDR_ENV=1 means you are currently inside a herdr pane.

Three gotchas found the hard way#

The pane is an interactive TTY, so pagers kick in#

git log, git diff, systemctl status and friends drop into less and sit there. The trailing && echo DONE never runs, so wait-output just times out.

sh
herdr pane run <pane_id> "git --no-pager log --oneline -3 && echo DONE"# or prefix with PAGER=cat

Completion markers must be unique per invocation#

wait-output searches the existing snapshot immediately, so a fixed marker matches leftover output from the previous command and reports a hit straight away. That is worse than a timeout: a timeout at least raises an error, a false positive convinces you the command finished.

sh
TAG="DONE_$$_$RANDOM"herdr pane run <pane_id> "pnpm test && echo ${TAG}_OK || echo ${TAG}_FAIL"herdr pane wait-output <pane_id> --regex "${TAG}_(OK|FAIL)" --source visible --timeout 120000

There is also no exit code coming back from a pane — success and failure have to be printed by the command itself.

Read output with --source visible#

recent / recent-unwrapped frequently come back with zero bytes. Don't use them to decide whether a command produced output:

--source visible            99 bytes--source recent              0 bytes--source recent-unwrapped    0 bytes

Running it under systemd (servers)#

Herdr's session.json restores the layout, cwds and pane labels — but not the commands that were running in those panes; what comes back is a clean shell. So autostart needs two layers: one to bring up the server, one to launch the services into their panes.

/etc/systemd/system/herdr.service:

ini
[Unit]Description=Herdr headless serverAfter=network-online.targetWants=network-online.target
[Service]Type=simpleUser=rootEnvironment=HOME=/rootEnvironment=PATH=/root/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/binEnvironment=TERM=xterm-256color# Must be set explicitly: systemd does not take the login shell from passwd and# falls back to bash, so panes end up without zsh and without the starship prompt# configured in .zshrcEnvironment=SHELL=/usr/bin/zshEnvironment=LANG=en_US.UTF-8ExecStart=/root/.local/bin/herdr serverExecStop=/root/.local/bin/herdr server stopRestart=on-failureRestartSec=3TimeoutStopSec=60KillMode=mixed
[Install]WantedBy=multi-user.target

herdr server is described upstream as the headless server; it needs no TTY.

Then a oneshot unit that launches the services once the server is up. Locate panes by label, not by pane id — ids change across restarts, labels don't:

sh
find_pane() {  # usage: find_pane <label>  for pid in $(herdr pane list | jq -r '.result.panes[].pane_id'); do    label=$(herdr pane get "$pid" | jq -r '.result.pane.label // ""')    [ "$label" = "$1" ] && { echo "$pid"; return 0; }  done  return 1}

With Requires=herdr.service + After=herdr.service, restarting herdr drags this unit along with it.

Using herdr on Windows over SSH#

Two limitations, both tied to the Windows version, and they pull in opposite directions — no single version is good for both.

Mouse input requires a Win11 host#

When clicking and scrolling do nothing over SSH, it is not that herdr failed to enable mouse capture — Windows 10's ConPTY drops the mouse reports before herdr can read them. ConPTY's mouse event translation only exists on Windows 11; it was never backported to 10, and the corresponding Microsoft Terminal issue is marked can't fix.

Nothing in the configuration works around this. It comes down to the host build:

BuildOSSSH mouse
>= 22000Windows 11works
19045Windows 10 22H2does not work

But Win11 cannot traverse junctions#

Conversely, Windows 11 24H2+ tightened reparse point traversal, so an SSH session cannot see through a junction:

herdr: The term 'herdr' is not recognized as a name of a cmdlet...

And herdr's install directory is exactly that — a junction:

%LOCALAPPDATA%\Programs\Herdr\bin  ->  ~\.herdr\packages\standalone\releases\<version>-x86_64-pc-windows-msvc

The tell is that the link shows fewer files than its target:

powershell
$l = "$env:LOCALAPPDATA\Programs\Herdr\bin"@(cmd /c "dir /b `"$l`" 2>nul").Count@(cmd /c "dir /b `"$((Get-Item $l -Force).Target)`" 2>nul").Count

What matters is not the path but who created the junction — the ones an installer creates cannot be traversed, the ones mklink /J creates can. (Print name length looked like the discriminator at first, but that was disproved: a mklink /J rebuild had a print name length of 62 and still traversed.)

Rebuilding fixes it; rmdir removes only the link, never the target:

powershell
$l = "$env:LOCALAPPDATA\Programs\Herdr\bin"$t = (Get-Item $l -Force).Targetcmd /c rmdir "$l"cmd /c mklink /J "$l" "$t"

This recurs on every herdr upgrade — the target path carries the version number, so a new release always means a new directory, and the junction the installer rebuilds is the kind that cannot be traversed. vite-plus's current follows the same pattern.

House rules#

  • Only close panes / tabs you created yourself; leave the user's alone
  • Always --no-focus for background work
  • Sequential commands reuse one pane. When you genuinely need parallelism, split down from the right-hand pane and cap the column at 3-4. Splitting right over and over just keeps halving the width
  • Never herdr server stop from an active session — it takes the pane processes with it