Skip to main content
  1. Posts/

Running six agents made tab patrol my biggest time sink. So: a herdr trial.

Nick Liu
Author
Nick Liu
Building infrastructure for Facebook Feed Ranking at Meta. Previously at Walmart, Twitter, AWS, and eBay. MS in Computer Science at Georgia Tech.
Table of Contents
Hardening My Dotfiles - This article is part of a series.
Part 5: This Article
Once I had six Claude Code sessions open at once, the most expensive part of my workflow was not writing code. It was patrol: cycling through tabs to see which agent was still running and which one had been sitting on a question for ten minutes. tmux has no concept of any of this. To tmux, every pane is a rectangle of terminal, equally interesting, equally mute.

herdr’s pitch lands exactly on that pain: panes are still real terminals, but a sidebar shows each agent’s live state. So it got a one-month trial, with exit conditions written down before it started. The verdict lands on August 16, and this post is honest about still being inside the window.

What herdr actually gives you
#

The panes are real terminals, not a chat UI wearing a terminal costume. The sidebar shows live agent state, which is the anti-patrol feature. And session restore is exact per pane: an integration hook reports each pane’s session ref, so restore runs claude --resume <session-id> for precisely the conversation that pane held.

The tmux comparison on that last point is what sold the trial. With resurrect plus a ~claude->claude --continue rule, tmux restore is an approximation: the moment two panes share a working directory, --continue cannot tell them apart and per-pane precision is gone.

Two plugins grew out of the trial along the way: reviewr, which binds prefix+e to open the agent’s diff in a side pane for inline comments, and tab-smart-rename, which names tabs after whatever topic the agent is currently working on. The patrol problem, attacked from two more angles.

The decision process mattered more than the tool
#

The first research pass on herdr concluded “wait for 1.0”. Safe advice. Almost nobody ever checks it.

The second pass did source-level verification instead: cloned the AGPL source, actually read keybinds.rs and xtgettcap.rs, ran headless server tests, and swept the plugin ecosystem. The conclusion flipped. Undercurl survives (through libghostty-vt), the kitty keyboard protocol is actively tracked, vim-navigation and sessionizer plugins exist, and the plugin manager pins by commit SHA via --ref. The ecosystem is around 500 plugins, and the top ten had all seen a commit within the past week. At that velocity, and with plugin repos sitting at 30 to 66 stars, pinning to a SHA is a survival requirement.

“Wait for it to mature” flipped to “trial it, with exits defined” only because someone read the source. That is a repeating theme this month.

The operations incident log
#

The wire protocol refuses any version skew. The moment an upgrade lands, a running server becomes unreachable to the new client. No negotiation, no grace. My first countermeasure was pinning the version. I later unpinned it deliberately, because the release velocity is part of what I am trialing, and moved the protection into daily maintenance: detect that an upgrade landed while a server is alive, and notify, instead of killing anything.

That guard is a pure function so it can be unit tested, and the tests bind a real unix socket and exercise all four polarities:

dm_herdr_strand_detected() {
    local before="$1" after="$2"
    local sock="${3:-$HOME/.config/herdr/herdr.sock}"
    [ -n "$before" ] && [ "$before" != "$after" ] && [ -S "$sock" ]
}
python3 -c "import socket; \
    socket.socket(socket.AF_UNIX).bind('$HERDR_UT_DIR/live.sock')"

run_test "herdr strand: mismatch + live socket -> detected" ...
run_test "herdr strand: same version -> silent" ...
run_test "herdr strand: no socket -> silent" ...
run_test "herdr strand: herdr absent (empty before) -> silent" ...

The guard may only ever call herdr --version. Any other subcommand can auto-start a server, and a server started from the maintenance job inherits the launchd environment. That is exactly the bug class behind a CLAUDECODE=1 leak that once broke zoxide in every pane. The rule is enforced by a tripwire test, because rules that live in comments decay:

# Tripwire: the maintenance run must never call the herdr CLI beyond
# --version, because any other subcommand can auto-start a server that
# inherits the launchd environment (the CLAUDECODE-leak bug class).
run_test "Maintenance calls herdr CLI only with --version" ...

The CLAUDECODE leak itself has an interesting fix. You cannot decide “is this a real agent shell” by checking interactivity, because Claude’s Bash tool replays a snapshot of the environment. The reliable signal is process ancestry: walk up the parent chain, and whichever you meet first decides.

claude*

herdr*

new shell in a herdr pane
sees CLAUDECODE=1

walk up the parent
process chain

which ancestor
appears first?

a real agent shell:
keep the flag

reached the pane's server:
the flag is leaked residue,
unset it

claude*

herdr*

new shell in a herdr pane
sees CLAUDECODE=1

walk up the parent
process chain

which ancestor
appears first?

a real agent shell:
keep the flag

reached the pane's server:
the flag is leaked residue,
unset it

if [[ -n "$HERDR_PANE_ID" && -n "$CLAUDECODE" ]]; then
    () {
        local pid=$PPID comm
        while [[ -n "$pid" && "$pid" != 0 && "$pid" != 1 ]]; do
            comm=$(command ps -o comm= -p "$pid" 2>/dev/null) || break
            case ${comm:t} in
                claude*) return ;;   # real agent shell: keep the flag
                herdr*)  break  ;;   # hit the server first: flag is residue
            esac
            pid=$(command ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ')
        done
        unset CLAUDECODE
    }
fi

When versions do skew, there is exactly one move, now written in the troubleshooting doc:

# cannot attach means cannot wind down gracefully; stop the server and
# let resume_agents_on_restore rebuild layout and Claude panes natively
herdr server stop
herdr

--remote splits your config down the middle
#

This came out of reading the source, not the docs, and a month of local usage never hinted at it. Under --remote, client and server each read their own slice of the config:

| Config section              | Read by (--remote)      |
| --------------------------- | ----------------------- |
| [keys]                      | client                  |
| [ui.toast]                  | client (once at attach) |
| [ui.sound]                  | client                  |
| [remote]                    | client                  |
| theme / terminal / session  | server                  |
| worktrees / experimental    | server                  |
| plugins (ALL of them)       | server                  |

Three consequences hide in that table. --remote-keybindings defaults to local, so editing keybindings on the remote host changes nothing, silently. [ui.toast] is read once at attach, so reload-config cannot touch a running client. And the real landmine: plugins run on the server, so every plugin must be installed on both machines, pinned to the same SHA. Miss one side and a key you bound locally just does nothing when you press it. Not an error. Nothing.

# run on BOTH machines, and --ref must match exactly
herdr plugin install <repo> --ref <sha>

The keybinding contract
#

Instead of choosing between two multiplexers, I made them identical to my hands. tmux and herdr now share the ctrl+a prefix and the layer under it:

| Key         | tmux             | herdr            |
| ----------- | ---------------- | ---------------- |
| C-a f       | open project     | open project     |
| C-a e       | review changes   | review changes   |
| C-a R       | reload config    | reload config    |
| C-a z       | zoom pane        | zoom pane        |
| C-a C-hjkl  | pane navigation  | pane navigation  |
| C-a C-a     | passthrough      | passthrough      |

The contract is a table in docs/keybindings.md, and any change on either side must update the table. The timing was deliberate: align during the trial, before muscle memory hardens, when the cost is a tenth of what it will be later. It also makes the verdict cheap. Whichever tool survives, my fingers already work.

Evidence against my own thesis
#

Recorded because a trial that only collects confirming evidence is a purchase, not a trial: tmux’s development branch (not yet in a brew release) has grown OSC 133 command hooks, including a pane-command-started event, plus substantial floating-pane support. The premise “tmux has zero awareness of what runs inside a pane” is weakening in real time, and that goes into the verdict with everything else.

The exit conditions
#

Written before the trial started, deciding on August 16:

  1. Did the sidebar actually reduce patrol?
  2. Did an upgrade actually bite a session?
  3. Did the orchestration API get real use?

Two of three in favor and herdr graduates, retiring sesh, resurrect, and continuum with it. Otherwise it gets removed, and “wait for 1.0” gets its win after all.

A trial without pre-written exit conditions is not a trial. It is a sunk cost waiting for enough weight to make the decision for you.

The verdict
#

(Added on the evening of August 16, verdict day.)

herdr graduates, on conditions 2 and 3.

No upgrade killed a session inside the window, so condition 2 holds. It was not free, though: the moving target cost integration work instead. PR #80 exists because herdr reads keybindings once, at client attach, so a config change does nothing until you re-attach. I count that as a paper cut, not a bite.

Condition 3 passed easily, and the proof arrived on decision day itself. The eza benchmark in the next post of this series produced a fake number inside a sandbox, and the true numbers came through herdr pane run driving a real terminal pane (PR #95). The orchestration API stopped being a nice extra that day. It was the only route to a correct measurement.

Condition 1 is the honest part of this verdict: I could not answer it. I tried to judge tmux usage from resurrect’s autosave timestamps and found the last save twelve days old. That gap was not tmux dying. The gap was the trial, because a month of deliberately using herdr makes herdr look load-bearing and tmux look dead, whichever way the tools actually compare. Data collected inside the window cannot answer “did the sidebar reduce patrol”. The trial is the data. So the verdict rests on two conditions, not three, and I am saying so instead of pretending it was three.

The retirement half of the rule did not fire. It changed shape. Graduation was supposed to retire sesh, resurrect, and continuum. Instead, herdr becomes the daily driver and tmux goes on standby, kept on purpose as the rollback path in case I need to switch back. Keeping the rollback path is a decision with a name, and it fits a trial built on written exit conditions better than a clean break would. It has a cost worth writing down, and the cost has the same shape as condition 1: daily use never exercises a standby path, so it can rot silently until the day I need it. The exit conditions decided the tool. The original retirement rule was written with more confidence than I actually had.

Lessons
#

  • The cost of running many agents is observation, not execution. Whatever tooling you pick should attack the patrol problem.
  • “Wait for it to mature” is the safest advice and the least verified. Reading the source flipped this decision once already.
  • Automation guards around a CLI must know which subcommands have side effects. A version check that can boot a server is not a check.
  • Environment flags cannot be trusted by interactivity; walk the process tree and let ancestry decide.
  • In split client/server systems, know which side reads which config. The failure mode of getting it wrong is silence, not errors.
  • Align keybindings across competing tools before muscle memory hardens, and the eventual migration, either direction, is free.
  • Write exit conditions before the trial. Sunk cost is very good at writing them afterward.
  • Data collected inside a trial window cannot judge the trial, because the trial is the data. Answer exit conditions with judgement, and say which ones you could not answer.

References
#

  • herdr (source; config-split and keybinding behavior verified against the source during the trial)
  • tmux CHANGES (OSC 133 command events and floating-pane work on the development branch)
  • tmux-resurrect (the approximation baseline for session restore)
  • The guard, tests, and keybinding contract: nickboy/dotfiles
Hardening My Dotfiles - This article is part of a series.
Part 5: This Article

Related

Git worktrees gave each Claude agent its own sandbox. And scattered my sessions.

··978 words·5 mins
I run four or more Claude Code agents at once, and until recently they all shared one working tree. Two agents editing the same repo means one of them eventually builds against the other's half-finished changes. Git worktrees fix that cleanly. What nobody warned me about is that the fix multiplies a different problem I already had: forgetting which folder a session lives in. Taming Claude Code Sessions · Part 5 of 6 1 2 3 4 5 6 🧪 Tested with Claude Code 2.1.x · macOS The symptom # With several agents in one directory, the working tree is shared mutable state. Agent A refactors a partial, agent B runs the build, and B’s “failure” is really A’s work in flight. I had been dodging this by scoping agents to different subdirectories, which works until it does not.

Running Several AI Coding Agents Without Losing Track

··867 words·5 mins
Once you're comfortable with AI coding agents, you start running several at once: one refactoring here, one writing tests there, one stuck waiting for your approval. Keeping them straight is its own little skill. Taming Claude Code Sessions · Part 4 of 6 1 2 3 4 5 6 🧪 Tested with Claude Code 2.1.x · macOS / Linux Here are two ways to do it: a lightweight tmux plugin, and (briefly) dedicated “AI terminal” apps.

Four review claims sounded right. Each took two minutes to disprove.

··1323 words·7 mins
After a month of overhauling my dotfiles with AI in the loop, the real value was not "the AI writes my configs". It was two much more boring properties: the research side keeps finding things I cannot see, and I verify every claim it makes before acting. Skip the first and you only ever fix problems you already knew about. Skip the second and a plausible-sounding wrong answer walks you into a ditch. The loop # claim fails verification