Trying herdr instead of tmux

I have been using tmux for a long time -- see my earlier posts on tmux sessions and naming Claude Code windows. Lately I tried herdr, a terminal workspace manager built specifically for running AI coding agents, and this post collects my setup.

Why herdr

tmux is a general-purpose multiplexer; herdr is opinionated about agents. It detects which agent is running in each pane (Claude, Codex, pi.dev, ...), tracks whether a pane is working, blocked or idle, and groups panes into workspaces and tabs. That agent awareness is the reason to use it over plain tmux. On the left below, a workspace with its sidebar of spaces, the tab bar and a shell pane; on the right, the sidebar's agents panel listing every running agent and its live status:

herdr workspace with a tab bar and paneherdr sidebar agents panel with status

One more thing tmux cannot do: herdr supports image paste, which doesn't work for me with tmux and Alacritty at all.

Configuration

herdr reads ~/.config/herdr/config.toml. After editing it, the running server picks up changes without a restart:

$ herdr server reload-config
{"result":{"diagnostics":[],"status":"applied","type":"config_reload"}}

A minimal config:

[theme]
name = "catppuccin-latte"

[keys]
prefix = "ctrl+a"
# cycle between agent sessions
next_agent = "ctrl+alt+n"
previous_agent = "ctrl+alt+p"
# prefix+up/down switch workspaces
previous_workspace = "prefix+up"
next_workspace = "prefix+down"
# line-by-line scrollback / copy mode
copy_mode = "prefix+["

[ui.sound]
enabled = false

Theming

The interesting part is not which theme I picked, but that herdr is themeable at all. It ships with a handful of built-in themes -- catppuccin (the default), tokyo-night, dracula, nord, gruvbox, one-dark, solarized, kanagawa, rose-pine, vesper -- plus a terminal theme that just inherits your terminal's own palette, and you can override individual color tokens on top of any of them.

Prefix key

Like in tmux, I rebind the prefix from Ctrl-b to Ctrl-a. The reasoning is the same one I described in my tmux sessions post: keeping the local prefix on Ctrl-a avoids the clash when I SSH into another machine, where the multiplexer is still on the default Ctrl-b.

Scrolling back through output

With herdr owning the mouse (mouse_capture = true, the default) the scroll wheel scrolls the focused pane's scrollback. For keyboard-driven scrolling, Page-Up / Page-Down work out of the box; for line-by-line I bind copy mode (unbound by default):

[keys]
copy_mode = "prefix+["

That gives a tmux-style scroll/select mode -- navigate with j / k (the arrows do not auto-repeat, see Downsides), search with /, Esc to leave.

There is also edit_scrollback (prefix+e), which dumps the pane's scrollback into $EDITOR. It is great for a shell, but useless for an agent panel: Claude's TUI is constantly redrawn, so the dump is a mess of repaint escapes rather than a clean transcript.

Disabling the attention sound

herdr plays a sound when an agent becomes blocked and needs attention. I found it annoying, so [ui.sound] enabled = false turns all sounds off.

Naming tabs after the running program

In my tmux setup I auto-renamed windows based on the running command. herdr has no built-in automatic-rename, but it exposes HERDR_TAB_ID inside every pane and a CLI to rename a tab, so the same idea works from zsh hooks.

The trick: rename the tab to the foreground program on preexec, and back to the shell name (zsh) on precmd. Using add-zsh-hook (instead of redefining preexec/precmd) keeps it independent of any existing tmux hooks, so both can run in parallel:

if [[ -n "$HERDR_TAB_ID" ]]; then
  autoload -Uz add-zsh-hook
  # name the tab after the running program (first word, no path)
  _herdr_preexec() { herdr tab rename "$HERDR_TAB_ID" "${${1%% *}:t}" >/dev/null 2>&1; }
  # back to the shell name when sitting at the prompt
  _herdr_precmd()  { herdr tab rename "$HERDR_TAB_ID" "${SHELL:t}" >/dev/null 2>&1; }
  add-zsh-hook preexec _herdr_preexec
  add-zsh-hook precmd  _herdr_precmd
fi

The [[ -n "$HERDR_TAB_ID" ]] guard makes the whole block a no-op outside herdr, so a machine that runs both tmux and herdr is fine.

Renaming a workspace

herdr names each workspace ("space" in the sidebar) after its folder -- the basename of the directory it was opened in. When that is not descriptive enough (several checkouts of the same repo, a generic src directory, ...), rename it. There is a keybinding, rename_workspace (prefix+shift+w), which prompts inline, or the CLI:

$ herdr workspace rename w1 "herdr"

That sets a custom_name that overrides the folder-derived label in the sidebar; the directory on disk is untouched, and the name persists across restarts. ($HERDR_WORKSPACE_ID holds the current workspace id inside any pane, so herdr workspace rename "$HERDR_WORKSPACE_ID" ... works from a shell.) To go back to the folder name, just rename it the empty string.

Moving a tab to another workspace

There is no built-in keybinding to move a tab to another workspace, but herdr has two pieces that combine nicely: custom command bindings ([[keys.command]]) and herdr pane move. A custom command runs with HERDR_ACTIVE_PANE_ID set to the pane that was focused when the key was pressed -- so it can act on the agent tab even though that tab is busy running an agent and you cannot type into it.

I bind prefix+m to a small script that pops up an fzf picker of workspaces and moves the focused tab to the chosen one:

fzf picker prompting "move tab to workspace" with a list of workspaces
[[keys.command]]
key = "prefix+m"
type = "pane"          # opens a temporary pane so fzf has a TTY
command = "~/.local/bin/herdr-move-tab"
#!/bin/sh
[ -n "$HERDR_ACTIVE_PANE_ID" ] || { echo "no active pane" >&2; sleep 2; exit 1; }

ws=$(herdr workspace list \
  | jq -r '.result.workspaces[] | "\(.workspace_id)\t\(.label // .workspace_id)"' \
  | fzf --prompt="move tab to workspace > " --with-nth=2 --delimiter='\t' \
  | cut -f1)
[ -n "$ws" ] || exit 0

# herdr refuses to move a pane out of a zoomed tab, so un-zoom it first.
herdr pane zoom "$HERDR_ACTIVE_PANE_ID" --off >/dev/null 2>&1
herdr pane move "$HERDR_ACTIVE_PANE_ID" --new-tab --workspace "$ws" --focus

The one gotcha worth highlighting: herdr pane move rejects the move with reason: zoomed_tab if the source tab is zoomed, hence the pane zoom --off line before the move.

Downsides

It is not all rosy. The things that bit me, roughly in order of annoyance:

Mouse: paste versus clean copy. This is the big trade, and I went back and forth on it. herdr defaults to mouse_capture = true so it can do click-to-focus, drag-to-resize, sidebar clicks, wheel-scroll a pane and -- crucially -- pane-aware selection that copies just that one pane. The cost: the terminal no longer runs its own primary-selection paste, so plain middle-click stops pasting. You paste with Shift+middle-click instead (Alacritty bypasses the application's mouse grab while Shift is held), and decades of plain-middle muscle memory take a bit to retrain.

I spent a couple of days on [ui] mouse_capture = false, which hands the mouse back to the terminal and restores plain middle-click paste. It turned out to be the worse side of the trade: you lose click-to-focus, drag-resize, sidebar clicks and wheel scrolling -- and, the dealbreaker, selection then runs on the terminal's whole grid, so dragging across a pane also grabs the sidebar, the / separators and any neighbouring pane. Copying a clean block of output became impossible. So I settled back on mouse_capture = true .

Idle CPU. The client redraws whatever the focused pane emits. A Claude Code pane animates (spinner, token counter) even while waiting for input, so the renderer sits at a few percent CPU rather than zero.

No automatic tab renaming. Unlike tmux's automatic-rename, herdr keeps a tab's name once set, so the zsh hooks above are doing the work tmux would do for free.

No full-screen / zen mode. toggle_sidebar (prefix+b) only collapses the left sidebar to a roughly one-character sliver -- it never fully disappears -- and the top tab bar is always on. There is no keybinding or config to show only the pane content. zoom (prefix+z) maximizes a pane within its tab, so with one pane per tab it does nothing visible.

Arrow keys do not auto-repeat. Holding an arrow key in a scroll/resize mode does nothing past the first press. herdr enables the kitty keyboard protocol, under which a held key is reported as a Repeat event -- and herdr only acts on the initial Press, dropping the repeats. There is no way to turn the protocol off in herdr (it pushes the enhancement flags unconditionally) or in Alacritty. Plain character keys are unaffected, so the quick workaround is to use j / k (and h / l) in copy mode instead of the arrows.

If you want the arrows themselves to repeat, you can override them in Alacritty so they emit the legacy escape sequences -- those arrive as plain presses that herdr does honour:

# ~/.config/alacritty/alacritty.toml
[[keyboard.bindings]]
key = "Up"
chars = "\u001B[A"   # and [B / [C / [D for Down / Right / Left

Modified arrows (Shift/Ctrl/Alt) are left alone, so the protocol still applies where it matters.

Prompt occasionally drops to the bare default. Every so often, inside herdr, my oh-my-zsh prompt renders correctly and then, after a command, collapses to the plain host% default. It is intermittent, which smells like a race: oh-my-zsh's async prompt computes segments in the background and calls zle reset-prompt from a file-descriptor handler, and that redraw seems to occasionally collide with herdr's own rendering. Disabling the async prompt inside herdr (add-zsh-hook -d precmd _omz_async_request, after sourcing oh-my-zsh) makes the prompt synchronous and should remove the race.

Conclusion

Seeing every active agent at a glance, while keeping most of what I relied on in tmux, is what convinced me to stick with herdr. It comes close enough to my old tmux setup, and the rough edges above are either worked around or minor enough to live with.

The Baldur's Gate 3 cloud-save trap

I play Baldur's Gate 3 without a local install. On a good connection I stream it via GeForce NOW, and when I travel I take my Steam Deck. Both run the Steam version, so Steam Cloud syncs the saves between them.

In a hotel I clicked one wrong button, overwrote the good cloud save with an older one, and spent an evening getting it back. I didn't lose anything in the end, but here is the trap and the way out.

The wrong click

On the Deck, Steam showed a cloud-conflict prompt and I clicked "Play Anyway."

That kept the Deck's older local saves and pushed them up, overwriting the newer cloud copy from my last GFN session. So: never blind-click "Play Anyway" on a conflict. Check which side is actually newer first -- it pushes local up and can replace the cloud.

Why sync was stuck

The real problem was underneath: Steam Cloud sync for BG3 had silently stopped, with no error and no warning.

BG3's Steam Cloud quota is about 2 GB. A single save here is 15 to 30 MB, and autosaves and quicksaves pile up fast. Once the quota is full, syncing just stops, and one stuck save jams the queue for every campaign, not only the one you are playing.

The fix was to get back under quota. Manual saves are not the problem -- autosaves and quicksaves are the bulk of it. I deleted a pile of old autosaves and quicksaves, and the sync finally completed.

Getting the good save back

What actually saved me: GFN keeps its own local copy of the saves, independent of Steam Cloud. I had assumed the cloud was the only place the good save lived, so overwriting it felt fatal. It wasn't -- the newer save was still sitting in GFN's own storage. The Deck has local storage too, but there it was only the older saves.

With sync working again:

  • On the next GFN launch a conflict prompt appeared, and this time I chose the GeForce NOW version -- the good one.

  • On the Deck I moved the local save folders aside before launching. Re-accepting the EULA had created a fresh profile with a "now" timestamp, which made Steam think local was newer. With the local folder empty, Steam downloaded the correct cloud copy.

Finding the save folder on the Deck

To move the saves aside I first had to find them. Don't trust hardcoded paths from the internet -- the popular ones are built around BG3's app ID (compatdata/1086940/...), and that did not match my setup.

In Desktop Mode, open Konsole and let find tell you:

find / -ipath "*Larian Studios/Baldur*Savegames*" 2>/dev/null

That prints the real path on your machine, ending in Larian Studios/Baldur's Gate 3/Savegames/Story. The Savegames/Story folder is the one to move aside.

Which status to trust

Steam's remote storage page was accurate the whole time. It showed exactly what was in the cloud, which turned out to be outdated saves -- the newer ones had never synced up and only existed in GFN's own storage. So trust the page, but read it for what it is: what's actually in the cloud, not necessarily your latest progress.

GeForce NOW's own "in sync" indicator is the one to distrust -- it reports saves as synced when they are not.

Even after the fix, GFN still falsely shows some older saves as "in sync." What reliably worked: from the last GFN save of each campaign, make a fresh manual save. Those sync up cleanly, as long as there is quota left for them.

CSV to ledger, revisited: matching, real dates, and a second bank

Back in 2022 I wrote about my ING DiBa csv to ledger converter. The closing line was "no additional magic" and I noted that I ignored the difference between booking date and effective date.

Three things changed since then:

  1. the script learned to categorize transactions instead of emitting Expenses:FIXME for everything,

  2. it learned to recover the real transaction date out of the VISA booking text, and

  3. I finally wrote a second converter, for my Revolut account, which had been sitting un-automated for a few years.

Auto-categorizing with a match table

The 2022 version produced one account for every row: Expenses:FIXME. I still copy entries into a ledger file per month by hand, but now most rows arrive pre-booked.

The core is a small list of matches -- a substring to look for in the transaction text, plus the account, payee and optional tags to emit:

@dataclass
class Match:
    match: str
    description: str
    account: str
    amount: Decimal | None = None
    tags: str | None = None

def process_match(string, amount=None):
    for item in [
        Match("Hetzner Online GmbH", "Hetzner", "Expenses:Infrastructure:Hetzner"),
        Match("Rundfunk ARD, ZDF, DRadio", "GEZ", "Expenses:Media:GEZ"),
        Match("LIDL SAGT DANKE", "Einkaufen", "Expenses:Supermarket:Lidl"),
        Match("LOGPAY FIN", "VVS Ticket", "Expenses:PublicTransport:VVS"),
        # ... lots more ...
        Match("", "FIXME", "Expenses:FIXME"),  # default
    ]:
        if item.match in string:
            if item.amount is not None and amount is not None:
                if abs(amount) != abs(item.amount):
                    continue
            return {
                "description": item.description,
                "account": item.account,
                "tags": item.tags,
            }

Expenses:FIXME is now the default at the bottom of the list instead of the only output. The first hit wins, so the list goes from specific to generic.

The amount field handles a wrinkle I did not anticipate: the same merchant string can mean different things depending on the amount. My Ionity charging is billed under one name, but €5.99 and €11.99 are two different subscription tiers, and anything else is an actual charging session:

Match(" IONITY", "Ionity Power Monthly", "Expenses:Car:Ionity:SubMotion",
      amount=Decimal("5.99"), tags="subscription-monthly:"),
Match(" IONITY", "Ionity Power Monthly", "Expenses:Car:Ionity:SubPower",
      amount=Decimal("11.99"), tags="subscription-monthly:"),
Match(" IONITY", "Ionity Charge", "Expenses:Car:Ionity:Charge39"),

The price for the charge can be €0.39, €0.49 or €0.65. I currently haven't automated this based on the previous monthly subscription. So I change the name of the account manually.

The real transaction date

In 2022 I wrote "I chose to only use the effective date" but a few weeks ago this annoyed me too much. Some subscription cycle calculations were off by a few days, because a card payment is booked a day or two after I actually swiped the card.

ING hides the real date in the VISA text as KAUFUMSATZ DD.MM (without the year). So I pull it back out:

KAUFUMSATZ_RE = re.compile(r"KAUFUMSATZ (\d{2})\.(\d{2})")

def kaufumsatz_date(comment: str, booking: date) -> date | None:
    m = KAUFUMSATZ_RE.search(comment)
    if not m:
        return None
    day, month = int(m.group(1)), int(m.group(2))
    try:
        d = date(booking.year, month, day)
    except ValueError:
        return None
    # KAUFUMSATZ always precedes booking; roll back a year across Jan/Dec wrap.
    return d.replace(year=booking.year - 1) if d > booking else d

The year is filled from the booking date and roll back one year if that would put the transaction after its own booking (the December/January wrap).

I only apply this where it matters -- the car charging and subscription entries, whose cycle analysis is day-sensitive. Everything else keeps the booking date, emitted as a date: tag only when it actually differs, so the journal stays uncluttered.

The thing that makes that analysis possible is not a tag but the account names themselves. Look back at the Ionity matches: Expenses:Car:Ionity:SubPower marks a monthly subscription tier, and Expenses:Car:Ionity:Charge39 is a charging session where the 39 is the price -- €0.39/kWh, encoded in the account name. My analysis script just asks hledger for Expenses:Car:.*:(Sub|Charge).* and reads the rate straight out of the account string. That structured naming is exactly what feeds my post on Ionity subscription calculations. Now I have exact dates which I didn't have when writing the Ionity post.

Adding a second bank: Revolut

I have had a Revolut account for years. For the first years of the account I manually converted the CSV export to transactions. I didn't use the card that much so this was not an issue. But since then I started to use virtual credit cards more often and this manual process kept me from updating my ledger files for quite a while now.

The CSV is a completely different shape from ING's: ISO dates, international number format, and columns for Type, Product and Fee. The conversion itself is the same idea as before; the interesting part turned out to be deciding what not to book.

  • Interest accrues on the savings pot in hundreds of tiny rows. I ignore all of them (for now).

  • The file contains the savings account's own view of every transfer as Product = Deposit rows. Those mirror the Current side I already book, so booking both would double-count. I keep only Current.

  • A couple of net-zero "balance migration to another region or legal entity" transfers are pure noise and get dropped.

Card payments map through the same match table. Revolut reports its small FX fee in a separate column, so it becomes its own posting whenever it is non-zero:

# Card Payment | GitHub
2025-06-28 Github
    Expenses:edu:simonwillison                 €8.57  ; subscription-monthly:
    Expenses:misc:ExchangeFee                  €0.09
    Assets:Revolut:Euro

Top-ups bridge to my ING account (Assets:Girokonto), and savings moves stay internal between Assets:Revolut:Euro and Assets:Revolut:InstantAccessSavings.

I am still not tracking the accounts of my investment depots. Only the money I transfer there. This is a task for another day.