COORD: 44.21.90
OFFSET: +12.5°
SYS.READY
BUFFER: 99%
FOCAL_PT
BACK TO DEVLOG
EXP

A Child Process Cannot cd Its Parent

exp's whole pitch is instant forking — fork a project and be there. But a child process can't change the parent shell's directory. The fix is an old Unix trick, and the harder problem turned out to be onboarding, not the mechanism.

2026-03-08 // RAW LEARNING CAPTURE
PROJECTEXP

The whole point of exp is instant forking. You say exp new "try redis", you get an APFS clone in a fresh directory, and you want to be there. But a child process cannot cd the parent shell — that's a Unix fundamental, not a bug I could engineer around. So for a while exp printed cd /path/to/fork and hoped you'd copy-paste it, or it opened a whole new terminal window.

The copy-paste was the tell. I'd assumed I'd want new terminal windows most of the time. In practice the real workflow is: I'm in a project, I have an idea, I want to fork and immediately land in the fork. New windows are for parallelism; most of the time I just want to switch context in-place.

The Trick Everyone Uses

The constraint is hard, so there's exactly one way around it, and nvm, zoxide, rbenv, pyenv, and direnv all use it: a shell wrapper function runs the binary, then reads a side-channel file to learn where to cd. The binary can't touch parent state, but the wrapper is the parent.

I called the side channel EXP_CD_FILE:

  1. exp shell-init outputs a shell function (zsh/bash/fish) that wraps the binary.
  2. You add eval "$(exp shell-init)" to your rc file.
  3. The wrapper creates a temp file, exports EXP_CD_FILE, and runs the real binary.
  4. Any command that wants to move calls writeCdTarget(dir) — writes the path to that temp file.
  5. After the binary exits, the wrapper reads the file and runs builtin cd "$target", then cleans up.

The binary doesn't know or care about the shell. The wrapper doesn't know or care which command ran. writeCdTarget() is five lines. Commands that participate — exp new, exp cd, exp home — just call it and suppress their own stdout path output when the wrapper is active.

When the fast-clone strategy landed in v0.7.0, the same file grew a tiny grammar without breaking anything:

cd:/path/to/fork
defer:node_modules

The wrapper reads cd: lines to change directory and defer: lines to spawn a background cp -cR for deferred dirs. Bare paths still work, so it stayed backwards compatible.

The Real Problem Was Onboarding

Having the mechanism is the easy half. Nobody types eval "$(exp shell-init)" from a README. I weighed a few options:

  • Manual README docs — nobody reads READMEs.
  • exp shell-init --install — requires already knowing it exists.
  • Proactive prompt in exp cd — ask once on first use. Winner.
  • Part of the exp init wizard — natural onboarding moment. Also a winner.

I shipped both. exp cd without the wrapper prompts once and remembers a decline via a shell_integration_prompted config key. exp init asks the question up front:

? Auto-cd into forks after creating them? (Y/n)
? Open a new terminal window after forking? (y/N)
? Which terminal? Ghostty (detected)
...
✓ Shell integration added to ~/.zshrc

The insight that made it click: don't ask "do you want shell integration?" — that's an implementation detail the user shouldn't have to hold. Ask "auto-cd into forks?", the thing they actually want, then silently install whatever makes it true.

An Opus audit of the v0.5.0 build caught the kind of bugs you only find by reading carefully: writeConfig() was doing a full overwrite and silently dropping custom keys like root (fixed with a merge), the cd prompt re-nagged on every invocation when declined, and the help text still claimed "terminal opens" after cd-first became the default.

After a few days the wrapper is invisible — I forget it's there. exp new "try something", and I'm in the fork with the dev server already in front of me. The lesson that generalizes: when a hard platform constraint forces a known workaround, the engineering is the cheap part. The work that decides whether anyone benefits is naming the feature after the outcome the user wants, not the mechanism you had to build.

LOG.ENTRY_END
ref:exp
RAW