A working configuration for Herdr that survives a reboot: shut the machine down, boot it back up, and every workspace, tab, pane and running AI agent conversation comes back where you left it.
Verified end-to-end on macOS with Herdr 0.7.4, Claude Code, and OpenCode.
Out of the box, Herdr persists your layout (workspaces, tabs, panes, cwd, focus) across a server restart. It does not automatically resume your agent conversations unless you install the agent integrations. Without them you reboot and get your panes back as empty shells, with every Claude/OpenCode session orphaned.
The single highest-value step in this guide is step 1.
Herdr resumes an agent only if it has a session ref for that pane. Session refs come from official integrations — hooks/plugins that the agent runs and that report its session ID back to Herdr over a socket. Screen detection alone is not enough: Herdr will show the agent in the sidebar but have no idea how to relaunch it.
herdr integration install claude
herdr integration install opencode
herdr integration status
Expected:
claude: current (v7) (~/.claude/hooks/herdr-agent-state.sh)
opencode: current (v8) (~/.config/opencode/plugins/herdr-agent-state.js)
herdr integration list
shows everything available: pi, codex, copilot, devin, droid, kimi, kilo, hermes, qodercli, cursor, mastracode, omp.
Integrations only tag sessions started after install. Agents already running when you install get no ref and will not resume on the next restart. This costs you exactly one cycle.A ref appears after activity, not at launch. The OpenCode plugin reports on session events (session.created
,session.updated
, tool calls). An agent sitting idle at its prompt may not have registered yet. Send one message, then check.Herdr won't install for an agent that isn't present.herdr integration install pi
fails withpi extension directory not found
if pi isn't installed. That's intentional — install the agent first, then the integration. Don't pre-create the directory; these integrations are versioned and a pre-staged file goes stale.
python3 -c "
import json
d=json.load(open('$HOME/.config/herdr/session.json'))
for w in d['workspaces']:
for ti,t in enumerate(w['tabs']):
for pid,p in t['panes'].items():
s=p.get('agent_session')
tab=t.get('custom_name') or f'tab{ti+1}'
print(f\"{(w.get('custom_name') or '(unnamed)'):18} {tab:18} pane {pid} -> \" +
(f\"{s['agent']:9} {s['value'][:16]} src={s['source']}\" if s else '(none)'))
"
You want src=herdr:claude
/ src=herdr:opencode
. That prefix means the integration
reported it. A pane with no agent_session
comes back as a plain shell.
onboarding = false
[ui]
agent_panel_sort = "spaces"
[session]
resume_agents_on_restore = true
[keys]
[[keys.command]]
key = "prefix+alt+q"
type = "shell"
command = "/Users/YOU/.local/bin/herdr server stop"
[ui.toast]
delivery = "terminal"
[theme]
name = "catppuccin"
auto_switch = false
Substitute your own username in the command
path — use an absolute path, since the command runs detached and may not inherit your interactive PATH.
Apply without restarting:
herdr server reload-config
Non-empty diagnostics
means a config error. See all available options with
herdr --default-config
.
Defaults to true
, but pin it explicitly so a future default change can't silently break your setup.
Replays recent terminal output after a restart, so panes look like you left them rather than
coming back blank. Leave it disabled unless you accept the tradeoff: it writes raw pane
scrollback to ~/.config/herdr/session-history.json
in plaintext, including anything that scrolled past (prompts, command output, tokens or keys you echoed).
To turn it on, add to config.toml
:
[experimental]
pane_history = true
Then harden it, because a one-shot chmod 600
on the history file does not hold. Herdr
rewrites the file as you work, and an atomic write (temp file plus rename) creates a new inode
at the server's umask, silently restoring the world-readable 644
on the next save. Lock the directory instead, which Herdr never recreates:
chmod 700 ~/.config/herdr # denies other local users regardless of any file's own mode
For the file to be owner-only from creation, also give the Herdr server a restrictive umask:
add <key>Umask</key><integer>63</integer>
(decimal for octal 077
) to the LaunchAgent in step 3.
Know the limit of all of this. Permissions only stop other local users. The scrollback is
still plaintext at rest, so it flows into Time Machine and any backup and stays readable by
anything already running as you. If real secrets pass through these panes (cloud credentials,
DB sessions, API tokens), do not enable pane_history
at all. No chmod makes plaintext secrets on disk safe.
There is no built-in "quit everything" binding. This is the most common point of confusion:
| Action | Key | Effect |
|---|---|---|
| Detach | ctrl+b q |
|
| Server keeps running. Every agent keeps running. Reattach and nothing was lost. | ||
| Stop | ctrl+b alt+q (the binding above) |
|
| Server stops, all panes/agents exit, layout is saved. |
prefix+q
is detach, not quit. Detaching before a reboot does nothing useful — macOS kills the server anyway, just less cleanly.
On macOS, use the left Option key for alt
. WezTerm (and most terminals) send left Option
as Alt while right Option composes characters like œ
. If the chord proves flaky, rebind to
prefix+shift+q
— no Alt involved.
Herdr installs no LaunchAgent. After a reboot nothing runs until you type herdr
. To have the session already restored by the time you open a terminal:
~/Library/LaunchAgents/dev.herdr.server.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>dev.herdr.server</string>
<key>ProgramArguments</key>
<array>
<string>/Users/YOU/.local/bin/herdr</string>
<string>server</string>
</array>
<key>RunAtLoad</key>
<true/>
<!-- Deliberately false: `herdr server stop` must stay stopped. -->
<key>KeepAlive</key>
<false/>
<key>WorkingDirectory</key>
<string>/Users/YOU</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/Users/YOU/.local/bin:/Users/YOU/.opencode/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
</dict>
<key>StandardOutPath</key>
<string>/Users/YOU/.config/herdr/launchd.out.log</string>
<key>StandardErrorPath</key>
<string>/Users/YOU/.config/herdr/launchd.err.log</string>
</dict>
</plist>
plutil -lint ~/Library/LaunchAgents/dev.herdr.server.plist
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/dev.herdr.server.plist
** KeepAlive must be false.** With
true
, launchd instantly resurrects the server after
your shutdown hotkey, making a clean shutdown impossible.If a server is already running, the job exits 1 with error: herdr server is already running
and leaves the running one alone. That's correct behavior, not a failure.
The PATH must list every agent's binary directory. Herdr relaunches agents by bare name (
claude
, opencode
), so a launchd-started server resolves them against this
PATH
, not
your interactive shell's. claude
lives in ~/.local/bin
; OpenCode installs to
~/.opencode/bin
, which is easy to leave out here. Omit a directory and that agent's panes
come back as empty shells even when the integration and refs are correct.To test the launchd path without rebooting: stop the server, then
launchctl kickstart -k gui/$(id -u)/dev.herdr.server
.
Herdr kills panes with SIGHUP
on shutdown. With default zsh settings, history is only flushed when a shell exits cleanly — so every command since your last clean exit is lost on every Herdr restart.
Symptom: ~/.zsh_history
has a modification time hours old while you've been working the whole time.
Add to ~/.zshrc
(not .zprofile
— see below):
HISTFILE=~/.zsh_history
HISTSIZE=50000
SAVEHIST=50000
setopt INC_APPEND_HISTORY # write each command to disk immediately
setopt EXTENDED_HISTORY # record timestamps
setopt HIST_IGNORE_SPACE # leading space keeps a command out of history
SHARE_HISTORY
is the stronger alternative — it also live-imports other panes' commands into
the current shell. Some find that disorienting since history order shifts underneath them;
INC_APPEND_HISTORY
gives durability without the cross-talk.
Note that zsh history is global, not per-pane. Up-arrow in any pane shows commands from
every pane, interleaved. Per-pane history would mean a separate HISTFILE
per pane, trading away the shared history most people want.
Verify:
zsh -i -c 'echo "SAVEHIST=$SAVEHIST"; [[ -o inc_append_history ]] && echo "inc_append=on"'
A very common misconfiguration is putting interactive setup in .zprofile
:
| File | Runs for |
|---|---|
.zshenv |
|
| every shell, including non-interactive scripts — env vars only | |
.zprofile |
|
| login shells only — PATH exports belong here | |
.zshrc |
|
| every interactive shell — aliases, completions, hooks, history |
It appears to work when everything you use happens to be a login shell (WezTerm, Terminal.app
and Herdr panes all spawn login shells on macOS by default). But any non-login interactive
zsh — a nested zsh
, some editor terminals, some container exec contexts — silently loses
your aliases, completions and direnv
hook.
Keep PATH
exports in .zprofile
: they use the export PATH="new:$PATH"
prepend form, so
running them in .zshrc
re-prepends on every nested shell and grows PATH without bound. Add
typeset -U path PATH
near the top of .zprofile
to dedupe automatically.
Confirm a shell is a login shell — ps
shows argv[0] with a leading dash:
ps -eo pid,ppid,args | grep zsh # "-zsh" = login shell
herdr # launch or attach
ctrl+b alt+q # stop everything (before a reboot)
herdr # bring it all back
Useful keys (defaults):
| Key | Action |
|---|---|
ctrl+b q |
|
| detach (leaves everything running) | |
ctrl+b w |
|
| workspace picker | |
ctrl+b c |
|
| new tab | |
ctrl+b v / ctrl+b - |
|
| split vertical / horizontal | |
ctrl+b z |
|
| zoom pane | |
ctrl+b b |
|
| toggle sidebar | |
ctrl+b ? |
|
| help | |
ctrl+b s |
|
| settings |
Snapshot before stopping:
cp ~/.config/herdr/session.json /tmp/session-before.json
After herdr
comes back, the agents should have been relaunched with the exact session IDs:
ps -eo pid,args | grep -E "[c]laude --|[o]pencode --"
opencode --session ses_1a2b3c4d5e6f...
opencode --session ses_7g8h9i0j1k2l...
claude --resume 11111111-2222-3333-4444-555555555555
claude --resume 66666666-7777-8888-9999-000000000000
...
Each ID should match its pane's agent_session
from the snapshot. That's the proof — Herdr built those command lines from the persisted refs.
Server lifecycle in the log:
grep -iE "shutdown|startup|persist.restore" ~/.config/herdr/herdr-server.log | tail
server shutdown initiated
herdr exiting event="app.shutdown" outcome="completed"
session restore evaluated event="persist.restore" outcome="ok" workspaces=5
herdr starting event="app.startup" outcome="started"
| Path | What |
|---|---|
~/.config/herdr/config.toml |
|
| configuration | |
~/.config/herdr/session.json |
|
| persisted layout + agent session refs | |
~/.config/herdr/session-history.json |
|
pane scrollback (if pane_history = true ) |
|
~/.config/herdr/herdr-server.log |
|
| server log | |
~/.config/herdr/herdr.sock |
|
| socket API |
| Command | What |
|---|---|
herdr status |
|
| client + server version/state | |
herdr --default-config |
|
| full annotated default config | |
herdr server reload-config |
|
| apply config.toml live | |
herdr server stop |
|
| stop the server | |
herdr integration status |
|
| which integrations are installed | |
herdr session list |
|
| named sessions | |
herdr update |
|
| self-update |
Session state saves are debounced roughly 5 seconds after a change, so a hard power-off costs at most the last few seconds of layout edits.
The agent sidebar lying by omission. A pane shows its agent via process detection even with zero integrations installed. Seeing OpenCode listed tells you it'srunning, not that it willresume. Onlyagent_session
insession.json
means resumable.Detach is not quit.prefix+q
leaves everything running.The first restart after installing integrations won't resume. Nothing had refs yet.Herdr passes no extra flags to agents. It relaunches withclaude --resume <id>
oropencode --session <id>
and nothing else — no environment beyondHERDR_PANE_ID
,HERDR_SOCKET_PATH
,HERDR_ENV
. If an agent comes back in an unexpected mode, Herdr isn't the cause.Terminal-level splits are invisible to Herdr. If your terminal has its own pane splitting (WezTerm'sCmd+d
, iTerm, tmux), panes created that way are not insession.json
, not restored, and not resumed. Inside a Herdr session, use Herdr's splits.