DEV Community

Artemii Amelin
Artemii Amelin

Posted on

launchd Gave Our Daemon Four Directories. Every Coding Agent on the Machine Was Installed Somewhere Else

The symptom was one line in the New session sheet: Claude Code was not found on this machine. The machine had Claude Code. It had Codex, Hermes and OpenClaw too, and the daemon reported none of them.

This is the fix that landed on main in shell.online today, in commit e7f891e. It is an old macOS problem, and coding agents make it worse than it used to be.

How detection works, and what it was looking at

shell.online lets you start an agent session on one of your machines from the browser. For that to work, a daemon on the machine reports which agent harnesses it can run. The list lives in cmd/shell/harness.go and has four entries: claude-code, codex, hermes, openclaw, each mapped to a command name. Detection is exec.LookPath and nothing more. The comment in that file is explicit that the tools are never executed to read a version string, because several of them take seconds to start and a label in a browser is not worth running an agent binary for.

So the whole test is "is it on PATH". Which raises the question of whose PATH.

Before v0.17.0 the daemon was started from a terminal and inherited the PATH of whoever ran it. Since v0.17.0, agreeing for the first time to let your browser start sessions on a machine also installs the daemon as a user service: a LaunchAgent on macOS, a systemd user unit on Linux. The changelog's reason: a machine you want to reach from a browser is a machine nobody is sitting at. It also means the daemon is no longer started by a shell.

launchd hands a service /usr/bin:/bin:/usr/sbin:/sbin. Four directories. Anthropic's setup docs say the native installer manages the launcher at ~/.local/bin/claude. Homebrew on Apple silicon is /opt/homebrew/bin. npm globals land wherever nvm or Volta points. On the machine where this was diagnosed, the commit message says all four harnesses sat outside those four directories, so the daemon found none, reported none, and the browser repeated what it was told.

The label was the smaller half of the problem. Sessions the daemon launches get os.Environ(), so a session started anyway would have died with "command not found" for the same reason.

None of this is new. Mutagen has an issue from February 2019 titled "launchd does not include /usr/local/bin in daemon path on macOS", where the daemon could not find docker. What changed is where the tools live. Agent CLIs are installed per user, by curl scripts and npm, into directories that only exist on PATH because an rc file put them there.

Asking the login shell instead of guessing

The fix is in cmd/shell/tool_path.go. At startup, before anything looks for a tool, the daemon runs the user's shell as $SHELL -lic with a script that prints $PATH, then calls os.Setenv once. Detection and launching both read the process environment, so neither needed to change.

The details are where the work went:

  • -i as well as -l. nvm is a shell function defined in an interactive rc file. A login-only shell never sees it, and the commit notes that two of the four harnesses are npm packages.
  • The answer is wrapped in markers, <<<shell-online-path: and :shell-online-path>>>, and parsed out of whatever else got printed. Interactive shells print greetings, version notices, a message of the day.
  • Five-second deadline, and stdin is nil. An rc file that waits on a prompt or a network call must not keep the daemon from starting.
  • If the markers are present, a non-zero exit status is ignored. Plenty of rc files end on a failing command and still exported a correct PATH on the way.
  • SHELL is often unset under launchd and systemd, so the fallback reads the user's shell from /etc/passwd, then defaults to /bin/zsh on macOS and /bin/sh elsewhere.
  • fish holds PATH as a list and prints it space-separated. A value with no colon, two or more fields, and every field starting with / gets rejoined with colons. A single directory with a space in its name is left alone, and there is a test for that.

The merge order is the terminal's PATH first, then whatever the service was given, then a fallback list. Terminal first is deliberate: if two claude binaries exist, the daemon should resolve the one your terminal resolves. Nothing is removed, so a machine where the probe fails ends up no worse than before.

The fallback list covers the case where the shell cannot be asked: ~/.local/bin, /opt/homebrew/bin, /usr/local/bin, ~/.bun/bin, ~/.cargo/bin, ~/go/bin, ~/.volta/bin and a few more, filtered to directories that exist. For nvm it reads ~/.nvm/alias/default and builds the versioned bin path from that, because sorting the versions directory lexically would prefer v9 to v22. On Windows there is no probe at all. The service manager already hands a service the machine and user PATH, so only the fallback directories apply.

What this costs

The daemon now runs your interactive rc files once per start. Same code that runs when you open a terminal, under your own account, but it is still a service executing .zshrc. The PATH is also computed once. Put a new directory on PATH in an rc file later and the daemon will not see it until it restarts.

The obvious alternative is to write PATH into the plist at install time. The service installer already carries a short list of variables across (servicePassthrough in cmd/shell/service.go, six SHELL_ONLINE_* names) and PATH is not one of them. A PATH frozen at install goes stale the first time nvm switches its default, which is the case the probe exists for.

We handle the same class of problem from the other direction in Pilot Protocol, where agents install the network stack themselves and the shells they run in do not read profiles. install.sh in the Pilot Protocol repo adds ~/.pilot/bin to the shell profiles, and when passwordless sudo is available it also symlinks pilotctl and pilot-daemon into /usr/local/bin. The script's header says why: so the CLI also resolves in non-interactive shells, and it lists bash -c, cron, CI and AI agents by name. There the tool makes itself findable by callers with a thin PATH. In shell.online the daemon is the caller with the thin PATH, and it has to go find everyone else's tools.

The message changed too

The browser note used to say the tool "was not found" and that the command "will fail there if the tool is not installed". It now says that if the tool is installed, update shell on that machine and run shell auth again, because older versions could not see the tools on your shell's PATH. The code comment next to it gives the reasoning: telling someone with the tool open in another window that it is not installed sends them looking for the wrong problem.

The fix is under Unreleased in the changelog as of today, after v0.19.0, so it is on main and not yet in a tagged release. If a machine with the service installed says it has no agents, this is the first thing to check.

Top comments (1)

Collapse
 
raju_dandigam profile image
Raju Dandigam

The login-shell probe fixes both detection and launch, and putting terminal PATH first preserves what the user expects. The part I’d want visible is provenance: for each harness, resolved absolute path, source segment (shell, service, or fallback), and binary digest or version. Otherwise a later nvm default switch can silently change what the daemon executes after restart. Does the UI expose the resolved path and probe source so a user can distinguish “not found” from “found a different binary”?