One dot-config Repository for two Notebooks

For a while my ~/.config on my main notebook has been a git repository, pushed to my private Forgejo. Now I wanted the same repository on my travel notebook. The catch: the notebooks are not identical. The main notebook runs sway on Wayland, the travel notebook runs i3 on X11. And some configs are simply different on each machine, on purpose.

Adopting the repository on the second notebook

I initialized git inside the existing folder on the travel notebook and pointed the branch at the remote without touching any files:

cd ~/.config
git init -b main
git remote add origin ssh://git@forgejo/mfa/dot-config.git
git fetch origin
git reset origin/main

After the git reset the working tree is unchanged, but git status shows exactly how this machine differs from the repository. The repository uses a deny-by-default .gitignore (first line is /*, then a !/name line for every config that is actually versioned), so all the application caches and state folders stay invisible.

Most differences were easy to sort: window manager configs don't conflict because sway/ and i3/ are different folders, so both live in the repository and each machine only uses its own. Some files existed only in the repository and were safe to check out. Some local drift was not worth keeping (btop, htop, mc) and I reset it to the repository version. And some files I stopped tracking completely: window geometry state like git/gitk and pavucontrol.ini, and uv/uv.toml.

But two files remained that genuinely need different content per machine: alacritty/alacritty.toml is tuned per display server and screen, and atuin/config.toml has a different sync_address, because the main notebook reaches the Atuin server over the LAN IP, while the travel notebook uses always the Tailscale address.

Alternatives I looked at

There are plenty of dotfile managers that solve the per-machine problem.

yadm was the closest match. Its "alternates" feature does natively what link.sh does: track alacritty.toml##hostname.foo and yadm symlinks the right one on checkout. But yadm wants $HOME as its worktree, so my ~/.config-rooted repository would need a path migration, and it brings encryption, templating, bootstrap hooks — a lot of tool for one symlink.

toml-bombadil has profiles, which is the right idea. But it renders every file through its template engine into a shadow directory and symlinks from there, so editing a config means re-linking afterwards. I wanted the profiles without the templating.

chezmoi is templating-first: one file with {{ if eq .chezmoi.hostname ... }} blocks instead of two files. Powerful, but the config in the repository is no longer the config on disk, and live editing is gone.

GNU stow is nicely minimal, but it symlinks from a package farm into ~/.config. My repository is ~/.config, and I didn't want to restructure everything into packages.

A branch per machine needs no symlinks at all, but means merge discipline forever, and in my experience those branches drift apart and rot.

For two notebooks and (currently) two per-host files, the shell script wins.

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.