# Herdr: persistent AI agent workspace setup for macOS — reboot-safe layout + Claude/OpenCode session resume

> Source: <https://gist.github.com/lasergoat/7f92d7c4ba72ee9889bb4b1fd6cd287a>
> Published: 2026-07-21 18:16:40+00:00

A working configuration for [Herdr](https://herdr.dev) 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 with`pi 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.

``` python
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 claude/opencode panes into their native conversations after a restart.
# Requires the agent integrations (herdr integration status).
resume_agents_on_restore = true

[keys]

# Shut the whole server down cleanly before a reboot (not just detach).
[[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:

``` js
herdr server reload-config
# => {"result":{"diagnostics":[],"status":"applied","type":"config_reload"}}
```

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's*running*, not that it will*resume*. Only`agent_session`

in`session.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 with`claude --resume <id>`

or`opencode --session <id>`

and nothing else — no environment beyond`HERDR_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's`Cmd+d`

, iTerm, tmux), panes created that way are not in`session.json`

, not restored, and not resumed. Inside a Herdr session, use Herdr's splits.
