01The problem
Plain Ghostty tabs sprawl. You end up with a dozen of them, and the moment you close the window your sessions seem to just disappear.
The goal was a single terminal that organizes itself around how the work actually splits up:
- Clients always present, sorted by frecency (frequency + recency), so the ones I touch most float to the top.
- A flagship product — the flagship product — with a dev space (complex, multi-session: research + engineering) and a deployments space (spinning up instances for new clients).
- A scratch space for random one-off work.
- iPhone access over Tailscale, so a running agent session is reachable from a phone.
Before landing on herdr, the tools tried were cmux, zellij, and tmux — each got part way, none nailed the whole picture. herdr (a terminal workspace manager built for AI coding agents) was the one that fit, but it needed configuring.
02The promptthe brief
The message that kicked off the session — lightly anonymized and cleaned up.
Hey — I just started using herdr. Plain Ghostty gives me tab sprawl and I keep losing track of my sessions. I want my client projects (one folder per client) always present, sorted by frecency; then our flagship product with a dev space (complex, multi-session — research and engineering in parallel) and a deployments space (I spin up instances for new clients); and a scratch space for random stuff. I previously tried tmux, zellij, and cmux — the old configs show what I mean. Research what people are actually doing, configure herdr for me, then teach me what you did so I get a highly productive Claude Code setup. I also want to reach all of this from my iPhone with Termius, and I use Tailscale — make it all work smoothly.
03What the session actually did
Ten beats, from cold start to shipped-and-validated.
- Found herdr 0.7.0 running on stock defaults, plus leftover zellij layouts — treated those old KDL layouts as the informal spec for what the setup should feel like.
- Launched two parallel research agents: one for an official-docs deep-dive, one for a community field report scraped from Hacker News, GitHub issues, and Reddit.
- Learned the load-bearing facts: no declarative layouts (you script the CLI), no native frecency, session state persists to
session.json. - Learned the Claude Code integration resumes conversations via
claude --resume, and that Homebrew installs cannot useherdr update --handoff. - Wrote
config.toml— keys, UI, toasts, one experimental flag. - Dispatched an implementer agent to write the bootstrap script against a written spec.
- Dispatched a peer-review agent over the resulting diff, then reconciled the feedback.
- Validated everything against a sandboxed second herdr server (
HERDR_SESSION+HERDR_SOCKET_PATH) with a stubbedclaudebinary — so no tokens were burned during testing. - Hit two real bugs in the process (see below) and fixed both.
- Shipped with a phone-access recipe for Termius + Tailscale + Mosh.
04The mental model
herdr nests four things. Agents are detected inside panes. The server owns the PTYs (pseudo-terminals — the actual running shells); clients just attach and detach.
● claude (working)
● claude (idle)
shell
Three persistence layers
These are independent — knowing which is which is the whole trick to never losing work.
1. Detach
Close the client and processes never stop. The server keeps every PTY alive; you reattach later and everything is exactly where you left it.
2. Restart
A full server restart restores from the session.json snapshot, plus — with the experimental pane_history flag — optional screen-content restore.
3. Claude resume
Claude conversations come back via the integration's saved session refs (claude --resume), independent of the terminal state.
05The config
The philosophy: only override what you mean to. Everything else keeps herdr defaults (see them with herdr --default-config). Reload live — no restart — with herdr server reload-config.
- zellij muscle memory:
alt+[/alt+]hop workspaces,alt+hjklmoves pane focus without the prefix. - Jump to workspace N:
prefix+shift+1..9. - Hunk-diff command pane:
prefix+alt+dopens a throwaway diff-viewer pane. mobile_width_threshold = 72: reliably triggers the single-column phone layout in Termius portrait.agent_panel_sort = "priority": agents needing attention float to the top of the sidebar.- Toast
delivery = "terminal": escalates to the outer terminal (Ghostty → real macOS notifications), works over SSH. pane_historytradeoff: restores screen contents after a restart, but scrollback may hold secrets at rest.
# herdr config — only overrides; everything else keeps herdr defaults. # Full annotated defaults: herdr --default-config # Apply changes to the running server: herdr server reload-config onboarding = false [keys] # Project-hopping muscle memory ported from zellij (Alt+[ / Alt+] there # switched project tabs; here projects are workspaces). # Requires Ghostty's macos-option-as-alt = true (already set). previous_workspace = "alt+[" next_workspace = "alt+]" # Jump straight to workspace N. switch_workspace = "prefix+shift+1..9" # zellij's Alt layer: move pane focus without the prefix. focus_pane_left = "alt+h" focus_pane_down = "alt+j" focus_pane_up = "alt+k" focus_pane_right = "alt+l" # Port of the claude.kdl hunk pane: prefix+alt+d opens a temporary # pane with the hunk diff viewer, closes when you quit it. # (Optional upgrade once on 0.7.4+: type = "popup" with width/height # floats it over the layout instead of splitting.) [[keys.command]] key = "prefix+alt+d" type = "pane" command = "hunk diff" [ui] # iPhone/Termius portrait can exceed 64 cols with a small font; 72 keeps # the single-column mobile layout kicking in reliably on the phone. mobile_width_threshold = 72 # Agents needing attention float to the top of the sidebar instead of # staying grouped under their workspace. agent_panel_sort = "priority" [ui.toast] # "terminal" escalates agent-needs-attention popups to the OUTER terminal # (Ghostty -> real macOS notifications) and works over SSH. Popups are # suppressed for the active tab, so only background work alerts you. delivery = "terminal" [experimental] # Restore recent pane screen contents after a full server restart (stored # in session-history.json). Trade-off: scrollback may contain secrets at # rest; acceptable on this single-user FileVault Mac. pane_history = true
06The bootstrap script
herdr has no declarative layouts, so the setup is a script against the socket CLI. Key design decisions:
- zoxide as the frecency source, with per-client scores summed across all subpaths under the client root.
- Idempotent by label — safe to re-run; each workspace is created only if its label is absent.
- Knobs:
MAX_CLIENTS,EXTRA_CLIENTS(always-include pins),EXCLUDE. HERDR_SPACES_CLAUDEoverride lets you swap in a stubclaudebinary for testing without burning tokens.CLIENT_ROOTis$HOME/Clientshere — whatever folder holds one subdirectory per client.
#!/usr/bin/env bash # herdr-spaces — idempotently bootstrap herdr workspaces (safe to re-run; each # workspace is keyed by its label and created only if absent). set -euo pipefail # ---- config (edit these) -------------------------------------------------- MAX_CLIENTS=10 # keep the top-N frecent clients EXTRA_CLIENTS=("$HOME/Projects/side-project") # always-include absolute paths EXCLUDE=() # client dir basenames to skip CLIENT_ROOT="$HOME/Clients" # frecency root (zoxide) FLAGSHIP="$HOME/Projects/flagship" CLAUDE_CMD="${HERDR_SPACES_CLAUDE:-claude}" # override for testing, e.g. a stub created=0; skipped=0 # ---- helpers -------------------------------------------------------------- die() { echo "herdr-spaces: $*" >&2; exit 1; } workspace_exists() { # $1=label herdr workspace list | jq -e --arg l "$1" 'any(.result.workspaces[]?; .label==$l)' >/dev/null } workspace_id_for() { # $1=label -> workspace_id | "" herdr workspace list | jq -r --arg l "$1" 'first(.result.workspaces[]? | select(.label==$l) | .workspace_id) // empty' } first_tab_id() { # $1=workspace_id -> lowest-numbered tab_id | "" herdr tab list --workspace "$1" | jq -r '((.result.tabs? // []) | sort_by(.number))[0].tab_id // empty' } tab_id_for_label() { # $1=workspace_id $2=label -> tab_id | "" herdr tab list --workspace "$1" | jq -r --arg l "$2" 'first(.result.tabs[]? | select(.label==$l) | .tab_id) // empty' } pane_id_for_tab() { # $1=workspace_id $2=tab_id -> pane_id | "" herdr pane list --workspace "$1" | jq -r --arg t "$2" 'first(.result.panes[]? | select(.tab_id==$t) | .pane_id) // empty' } is_excluded() { local x; for x in "${EXCLUDE[@]}"; do [[ "$1" == "$x" ]] && return 0; done; return 1; } run_in_tab() { # $1=workspace_id $2=tab_label $3=command — type command into that tab's shell local tid pid tid=$(tab_id_for_label "$1" "$2"); [[ -n "$tid" ]] || { echo " ! no tab '$2' in $1" >&2; return; } pid=$(pane_id_for_tab "$1" "$tid"); [[ -n "$pid" ]] || { echo " ! no pane for '$2' in $1" >&2; return; } herdr pane run "$pid" "$3" >/dev/null } ensure_client() { # $1=dir $2=label $3=note if is_excluded "$2"; then echo "skip $2 (excluded)"; skipped=$((skipped+1)); return; fi [[ -d "$1" ]] || { echo "skip $2 (missing dir)"; skipped=$((skipped+1)); return; } if workspace_exists "$2"; then echo "skip $2 (exists)"; skipped=$((skipped+1)); return; fi echo "create workspace $2 ($3)" herdr workspace create --cwd "$1" --label "$2" --no-focus >/dev/null; created=$((created+1)) } setup_ws() { # $1=label $2=tab1 $3=cmd1 $4=tab2 $5=cmd2 — create the 2-tab ws only if absent local wid tid if [[ ! -d "$FLAGSHIP" ]] || workspace_exists "$1"; then echo "skip $1 (exists or missing)"; skipped=$((skipped+1)); return fi echo "create workspace $1" herdr workspace create --cwd "$FLAGSHIP" --label "$1" --no-focus >/dev/null; created=$((created+1)) wid=$(workspace_id_for "$1"); [[ -n "$wid" ]] || { echo " ! lost $1 after create" >&2; return; } tid=$(first_tab_id "$wid") if [[ -n "$tid" ]]; then herdr tab rename "$tid" "$2" >/dev/null || echo " ! rename failed in $1" >&2 fi sleep 1; run_in_tab "$wid" "$2" "$3" herdr tab create --workspace "$wid" --cwd "$FLAGSHIP" --label "$4" --no-focus >/dev/null \ || { echo " ! tab create failed in $1" >&2; return; } sleep 1; run_in_tab "$wid" "$4" "$5" } # ---- preflight ------------------------------------------------------------ command -v jq >/dev/null 2>&1 || die "jq not found" command -v herdr >/dev/null 2>&1 || die "herdr not found" # No pipes here: grep -q closes the pipe early and herdr panics on # SIGPIPE (exit 101), which pipefail then propagates. Capture instead. status_out="$(herdr status 2>/dev/null)" || true [[ "$status_out" == *"status: running"* ]] || die "no running herdr server" if [[ "$status_out" == *"compatible: no"* ]]; then die "client/server protocol mismatch — restart the herdr server first (herdr server stop; herdr)" fi # ---- 1. client workspaces, ranked by summed zoxide frecency --------------- client_lines=() if command -v zoxide >/dev/null 2>&1; then mapfile -t client_lines < <( zoxide query -ls 2>/dev/null | awk -v root="$CLIENT_ROOT/" ' { line=$0; sub(/^[ \t]*[^ \t]+[ \t]+/,"",line) # drop score, keep the path if (index(line,root)==1) { rest=substr(line,length(root)+1); s=index(rest,"/") c=(s>0)?substr(rest,1,s-1):rest # first component = client dir if (c!="") sum[c]+=($1+0) } } END { for (c in sum) printf "%.1f\t%s\n", sum[c], c }' \ | sort -rn | head -n "$MAX_CLIENTS" ) else echo "herdr-spaces: zoxide not found, skipping frecency clients" >&2 fi seen=() for line in "${client_lines[@]}"; do score=${line%%$'\t'*}; client=${line#*$'\t'} ensure_client "$CLIENT_ROOT/$client" "$client" "score $score"; seen+=("$CLIENT_ROOT/$client") done # ---- 2. pinned extra clients (dedup against the frecency set) ------------- for dir in "${EXTRA_CLIENTS[@]}"; do dup=0; for s in "${seen[@]}"; do [[ "$s" == "$dir" ]] && { dup=1; break; }; done [[ $dup -eq 1 ]] && continue ensure_client "$dir" "$(basename "$dir")" "pinned" done # ---- 3 & 4. flagship dev + deploy ------------------------------------------ # NOTE: prompts below are typed into the pane wrapped in single quotes — # keep apostrophes out of them (use em dashes / rewording instead). setup_ws "flagship dev" research "$CLAUDE_CMD" eng "$CLAUDE_CMD" deploy_cmd="$CLAUDE_CMD 'You are the deploy operator for this product. Read the Redeploying section of CLAUDE.md. Wait for my instructions — when I say deploy, run the smoke check (bash tests/local_smoke.sh) first, then bash deploy/redeploy.sh, then verify healthz. You also handle provisioning new boxes when I ask.'" monitor_cmd="$CLAUDE_CMD 'You are the monitor for this product. Check https://yourapp.example/healthz, systemd unit status on the app box (ssh root@yourbox), and recent logs. Give me a one-screen health summary now, then wait for my instructions. Read-only: never restart or deploy anything.'" setup_ws "flagship deploy" deploy "$deploy_cmd" monitor "$monitor_cmd" # ---- 5. scratch (skip if a "scratch" or the current "~" workspace exists) -- if workspace_exists scratch || workspace_exists "~"; then echo "skip scratch (exists)"; skipped=$((skipped+1)) else echo "create workspace scratch" herdr workspace create --cwd "$HOME" --label scratch --no-focus >/dev/null; created=$((created+1)) fi echo "$created created, $skipped skipped" # --------------------------------------------------------------------------- # Pin a client: add its absolute path to EXTRA_CLIENTS, e.g. # EXTRA_CLIENTS=("$HOME/Projects/side-project" "$HOME/Projects/x") # Exclude a client: add its directory basename to EXCLUDE, e.g. EXCLUDE=("OldClient"). # Show more/fewer frecent clients: change MAX_CLIENTS at the top.
With the client root scanned and scores summed, the ranked output feeding the top-N looks like:
23.5 ClientOne 8.4 ClientTwo 5.0 ClientThree
07Two real bugs found
The war-story section. Both were found during validation, before anything shipped.
Piping herdr into grep -q panics it under pipefail
herdr status | grep -q ... fails deterministically in bash with set -o pipefail. Here's the chain: grep -q exits at the first match and closes the pipe → herdr (a Rust binary) gets SIGPIPE on its next write and panics with exit 101 → pipefail sees the non-zero producer and propagates it. Your script dies for no visible reason.
Never pipe herdr into an early-exiting consumer. Capture the output with $( ) and substring-match instead:
status_out="$(herdr status 2>/dev/null)" || true [[ "$status_out" == *"status: running"* ]] || die "no running herdr server"
The "sandbox" server wrote back to the live session.json
herdr session persistence is per-user — session.json lives next to the default config, and is not isolated by HERDR_CONFIG_PATH or HERDR_SOCKET_PATH. So a "sandbox" server started on a different socket happily loaded and then wrote back the live session.json — nearly clobbering the real workspace state during testing.
True isolation needs a named session. Set both:
HERDR_SESSION=sandbox \ HERDR_SOCKET_PATH=/tmp/herdr-sandbox.sock \ herdr
State then lives under ~/.config/herdr/sessions/sandbox/, fully separate from the live session.
08Phone access (iPhone, Termius, Tailscale)
Any SSH client works; this uses Termius over Tailscale. The whole point: reconnect to a running agent session from a phone, with agents still churning in the background.
- Install the Tailscale app: https://apps.apple.com/app/tailscale/id1470499037
- Install the Termius app: https://apps.apple.com/app/termius-terminal-ssh-client/id549039908
- New host: address
your-mac.your-tailnet.ts.net, port22. - Set the startup snippet to just
herdr— leave "close session after running" OFF and leave targets unset. - Enable both "Use SSH" and "Use Mosh".
- Set the Mosh command (the gotcha below) so mosh-server is actually found:
/opt/homebrew/bin/mosh-server new -s -c 256 -l LANG=en_US.UTF-8
Why: non-interactive shells don't see Homebrew's PATH, so Termius can't find mosh-server unless you give the absolute path. With mobile_width_threshold at 72, anything ≤72 columns drops you into the single-column mobile layout automatically.
The Mac must stay awake (or it can't serve the session). Expect a one-time macOS firewall prompt for mosh-server. Moshi is a mosh-native iOS alternative to Termius. And remember: detach is ctrl+b then q — nothing dies when you do it.
09Cheat sheet
Prefix is ctrl+b throughout.
| Keys | Action |
|---|---|
| alt+[ / alt+] | Previous / next workspace |
| prefix shift+1..9 | Jump to workspace N |
| prefix g | Goto (workspace picker) |
| prefix w | Picker |
| prefix o | Jump to notification |
| prefix c | New tab |
| prefix 1..9 | Switch to tab N |
| alt+h/j/k/l | Move pane focus (no prefix) |
| prefix v / prefix - | Split pane (vertical / horizontal) |
| prefix z | Zoom pane |
| prefix e | Open scrollback in editor |
| prefix shift+g | New git worktree |
| prefix q | Detach (nothing stops) |
| prefix alt+d | Hunk diff pane |
10Sharp edges & credits
- Claude state is screen-detected. herdr infers agent status by watching the pane's screen, so it can stick on "idle" when Claude is actually working (tracked as issue #1630). Don't fully trust the dot.
- Homebrew installs update differently.
herdr updateis disabled on brew installs. Update withbrew upgrade herdr, thenherdr server stop && herdrto restart on the new binary. - Worktrees are the community's killer feature for running parallel agents on one repo:
herdr worktree create --branch xgives each agent its own checkout so they don't step on each other.