Skip to content

Open Ghostty into herdr, and never into nothing - #100

Merged
jfmercer merged 2 commits into
masterfrom
ghostty-launches-herdr
Aug 2, 2026
Merged

Open Ghostty into herdr, and never into nothing#100
jfmercer merged 2 commits into
masterfrom
ghostty-launches-herdr

Conversation

@jfmercer

@jfmercer jfmercer commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Opening Ghostty now lands in herdr: the server starts if it is not running, and attaches to the existing session if it is.

The decision is herdr's, not ours. Bare herdr is documented as "Launch or attach to the persistent session," so there is no herdr status check here to duplicate and later disagree with it.

Why a wrapper at all

Three things herdr cannot do for itself when the GUI starts it rather than a shell:

  1. Find the binary. A GUI launch inherits none of the PATH that homebrew/homebrew.zsh and system/path.zsh build — PATH=/usr/bin:/bin sh -c 'command -v herdr' fails — so initial-command = herdr would not have resolved on this machine. find_herdr falls back to a prefix loop, the same problem and fix as in run_onchange_before_10_homebrew.sh.tmpl.
  2. Not lock you out. Ghostty closes the window when its command exits, so every path here ends at exec "${SHELL:-/bin/zsh}" -l: herdr missing, herdr failing, and herdr detaching (ctrl-a q) all leave a usable prompt. herdr runs as a child, not exec, so a crash leaves its output on screen.
  3. Not nest. herdr exports HERDR_ENV in every pane and its allow_nested is false by default. HERDR_AUTOSTART=0 is the manual escape hatch.

Two deliberate trade-offs

  • initial-command, not command, so new windows and Cmd-T tabs stay plain shells. Per Ghostty's own docs it fires only for the first surface ever created — close that window and the next one is a plain shell until Ghostty restarts. Accepted, not overlooked.
  • shell-integration left at detect. Forcing zsh would give the fallback shell Ghostty's cursor/title features, but it injects ZDOTDIR, and since no shell runs before herdr to restore it, that would leak into the herdr server and every pane's shell — changing every pane to fix cosmetics on a rescue path. Noted in CLAUDE.md so it does not get "fixed" later.

Why the config became a template

The path must be absolute and point at the chezmoi source directory: bin/ is in .chezmoiignore and runs in place, so no copy of the script ever exists under $HOME. A homeDir path would render fine and then never resolve. An invariant test asserts both halves.

Tests

tests/bats/ghostty_session.bats (12 tests) defends the boring property — every path ends at a login shell — plus two cross-file invariants tying the Ghostty config to the script.

herdr_candidates is a function rather than a constant so tests can redirect it; left alone, the "herdr is not installed" test would find the real /opt/homebrew/bin/herdr and attach the suite to a live session. The suite also unsets HERDR_ENV in setup: it is normally run from inside a herdr pane, and without that every test took the nesting guard and passed for the wrong reason (which is exactly what happened on the first run).

Three new rows in tests/README.md's breakage table, each verified red:

Break Failed
"$herdr" || rc=$?"$herdr" 1 test
drop the final exec_login_shell 4 tests
rename the script in the config only 7 tests

Verification

  • ./tests/run green — 83 bats, 56 unittest, nothing skipped
  • shellcheck bin/ghostty-session clean; it is picked up by CI's shebang-based bin/* selection with no workflow change
  • ghostty +validate-config exits 0 on the rendered config
  • find_herdr resolves /opt/homebrew/bin/herdr under env -i PATH=/usr/bin:/bin — the point of item 1 above
  • Guard and not-found branches exercised directly; chezmoi apply run, leaving only the four permanently-pending run_after scripts in chezmoi status

Verified end to end after the fact: Ghostty quit and reopened attaches to the existing session; and with herdr server stop first, a reopen launches a fresh server. Both branches confirmed on a real machine. The PATH caveat is closed too — a pane from the wrapper-launched server has the full PATH (Homebrew, asdf shims, $DOTFILES/bin), since pane login shells rebuild it regardless of what the server inherited.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JyYEk73g3uJoifwcSwj9Bg

jfmercer and others added 2 commits August 2, 2026 12:47
Bare `herdr` already launches or attaches -- it starts the headless server
if one is not running and attaches to the existing session if one is. So
this adds no status check; the decision stays herdr's.

What the wrapper adds is the three things herdr cannot do for itself when
the GUI starts it rather than a shell:

  1. Find the binary. A GUI launch inherits none of the PATH that
     homebrew/homebrew.zsh and system/path.zsh build -- `PATH=/usr/bin:/bin
     sh -c 'command -v herdr'` fails -- so `initial-command = herdr` would
     not have resolved. Same problem and same fix as the prefix loop in
     run_onchange_before_10_homebrew.sh.tmpl.
  2. Not lock you out. Ghostty closes the window when its command exits, so
     every path here ends at `exec "${SHELL:-/bin/zsh}" -l`. herdr runs as a
     child, not exec: detaching leaves a prompt in the same window and a
     crash leaves its output on screen.
  3. Not nest. herdr exports HERDR_ENV in every pane and its allow_nested is
     false by default.

`initial-command`, not `command`, so new windows and Cmd-T tabs stay plain
shells. The cost, per Ghostty's docs: it fires only for the first surface
ever created, so a window opened after that one closes is a plain shell
until Ghostty restarts.

The config becomes a template because the path must be absolute and must
point at the chezmoi SOURCE directory -- bin/ is in .chezmoiignore and runs
in place, so no copy of the script exists under $HOME.

herdr_candidates is a function rather than a constant so tests can redirect
it; left alone, the "herdr is not installed" test would find the real
/opt/homebrew/bin/herdr and attach the suite to a live session. The suite
also unsets HERDR_ENV, since it is normally run from inside a herdr pane --
without that every test took the nesting guard and passed for the wrong
reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JyYEk73g3uJoifwcSwj9Bg
@jfmercer
jfmercer force-pushed the ghostty-launches-herdr branch from eecf309 to c9423dc Compare August 2, 2026 17:24
@jfmercer
jfmercer merged commit 98d5582 into master Aug 2, 2026
10 checks passed
@jfmercer
jfmercer deleted the ghostty-launches-herdr branch August 2, 2026 17:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant